Carousel
stimeo--carousel
A slideshow with pausable autoplay and a roving tablist of slide pickers.
The stimeo--carousel controller implements the WAI-ARIA Carousel (tabbed) pattern. It moves between slides (next / prev / goto), syncs the active slide with data-state and the hidden attribute (so inactive slides leave the focus order), and keeps the matching picker's aria-selected and the single roving tabindex in step. The play/pause toggle mirrors autoplay on aria-pressed. Autoplay honors WCAG 2.2.2: it suspends while the pointer hovers and hard-stops when keyboard focus enters — it does not silently resume on focus out, so motion never surprises a keyboard user. The interval is cleared on disconnect (Turbo navigation included). Arrow keys on the pickers move focus only (manual activation); Home/End jump to the first/last slide. Behavior only — transitions and layout are yours.
For accessibility (WCAG 2.2.2) autoplay pauses while you hover and stops when keyboard focus enters the carousel, so motion never interferes with interaction (it won't auto-resume on focus out). Use the play button to resume.
Keyboard
| Key | Action |
|---|---|
| Enter / Space | Activate a button (prev / next / play-pause / select a slide). |
| → / ← | Move focus to the next / previous slide picker (roving). |
| Home / End | Activate the first / last slide. |
<%# Markup for the carousel demo.
The library handles slide advance, autoplay, syncing the current slide's
data-state / hidden, and the pickers' (tabs) aria-selected and roving.
Autoplay deliberately demonstrates WCAG 2.2.2: it pauses while the pointer hovers
and hard-stops when keyboard focus enters the carousel (it does not auto-resume on
focus out — press play). This is intentional and is spelled out in the on-screen
note below the carousel. Transitions and layout are the consumer's CSS. %>
<section
class="carousel"
data-controller="stimeo--carousel"
aria-roledescription="carousel"
aria-label="<%= t("components.carousel.demo.label") %>"
data-stimeo--carousel-autoplay-value="false"
data-stimeo--carousel-interval-value="2500"
data-stimeo--carousel-loop-value="true"
data-action="
mouseenter->stimeo--carousel#pause
mouseleave->stimeo--carousel#resume
focusin->stimeo--carousel#pause
focusout->stimeo--carousel#resume">
<div class="carousel__bar">
<button
type="button"
class="carousel__play"
aria-pressed="false"
aria-label="<%= t("components.carousel.demo.autoplay") %>"
data-stimeo--carousel-target="playToggle"
data-action="click->stimeo--carousel#togglePlay">
<%# The library toggles aria-pressed; demo.css swaps the glyph off it so the
control visibly reflects play (❚❚) vs. paused (▶). The icons are decorative;
the accessible name comes from aria-label above. %>
<span class="carousel__play-icon carousel__play-icon--play" aria-hidden="true">▶</span>
<span class="carousel__play-icon carousel__play-icon--pause" aria-hidden="true">❚❚</span>
</button>
<p
class="carousel__status"
data-carousel-status
data-template="<%= t("components.carousel.demo.status_template") %>"
data-playing-suffix="<%= t("components.carousel.demo.status_playing") %>"
aria-live="polite"></p>
</div>
<div class="carousel__viewport" data-stimeo--carousel-target="viewport">
<% t("components.carousel.demo.slides").each_with_index do |slide, i| %>
<div
id="carousel-slide-<%= i + 1 %>"
class="carousel__slide"
role="tabpanel"
aria-roledescription="slide"
aria-label="<%= "#{i + 1} of #{t('components.carousel.demo.slides').size}" %>"
aria-labelledby="carousel-dot-<%= i + 1 %>"
data-stimeo--carousel-target="slide"
<%= "hidden" if i.positive? %>>
<h3 class="carousel__title"><%= slide[:title] %></h3>
<p><%= slide[:body] %></p>
</div>
<% end %>
</div>
<div class="carousel__nav">
<button type="button" class="carousel__arrow"
aria-label="<%= t("components.carousel.demo.prev") %>"
data-stimeo--carousel-target="prev"
data-action="click->stimeo--carousel#prev">‹</button>
<button type="button" class="carousel__arrow"
aria-label="<%= t("components.carousel.demo.next") %>"
data-stimeo--carousel-target="next"
data-action="click->stimeo--carousel#next">›</button>
</div>
<div class="carousel__dots" role="tablist"
aria-label="<%= t("components.carousel.demo.tablist") %>">
<% t("components.carousel.demo.slides").each_with_index do |slide, i| %>
<button
id="carousel-dot-<%= i + 1 %>"
class="carousel__dot"
role="tab"
aria-selected="<%= i.zero? %>"
aria-controls="carousel-slide-<%= i + 1 %>"
aria-label="<%= slide[:title] %>"
tabindex="<%= i.zero? ? 0 : -1 %>"
data-stimeo--carousel-target="picker"
data-action="click->stimeo--carousel#goto
keydown->stimeo--carousel#onPickerKeydown"></button>
<% end %>
</div>
</section>
<%# On-screen explanation of the deliberate autoplay accessibility behavior, so the
pause-on-hover / hard-stop-on-focus is understood as intentional (WCAG 2.2.2), not a bug.
Kept outside the <section> so reading it doesn't itself pause the carousel. %>
<p class="carousel__hint"><%= t("components.carousel.demo.hint") %></p>
/*
* Presentation-only styles for the carousel demo.
* The library toggles the current slide's data-state / hidden, the pickers'
* aria-selected / tabindex, and the play toggle's aria-pressed. The visual
* switching and styling are built here.
*/
.carousel {
max-width: 28rem;
padding: 0.75rem;
border: 1px solid var(--border-strong);
border-radius: 0.75rem;
background: var(--surface, var(--surface-card));
}
.carousel__bar {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.6rem;
}
.carousel__play {
padding: 0.3rem 0.55rem;
border: 1px solid var(--border-strong);
border-radius: 0.4rem;
background: none;
font: inherit;
cursor: pointer;
}
.carousel__play[aria-pressed="true"] {
border-color: var(--accent, var(--color-primary));
background: var(--vital-100);
color: var(--accent, var(--color-primary));
}
/* Swap the glyph off aria-pressed: ▶ when paused, ❚❚ while autoplay runs. */
.carousel__play-icon--pause {
display: none;
}
.carousel__play[aria-pressed="true"] .carousel__play-icon--play {
display: none;
}
.carousel__play[aria-pressed="true"] .carousel__play-icon--pause {
display: inline;
}
.carousel__status {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}
/* Explains the deliberate WCAG 2.2.2 autoplay behavior (pause on hover / stop on focus). */
.carousel__hint {
max-width: 28rem;
margin: 0.6rem 0 0;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-muted);
}
.carousel__viewport {
min-height: 6rem;
padding: 1rem;
border-radius: 0.5rem;
background: var(--surface-subtle);
}
/* Inactive slides are hidden via the hidden attribute, so only the visible one needs styling. */
.carousel__slide[data-state="active"] {
animation: carousel-fade 0.25s ease;
}
@keyframes carousel-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.carousel__slide[data-state="active"] {
animation: none;
}
}
.carousel__title {
margin: 0 0 0.4rem;
font-size: 1rem;
}
.carousel__nav {
display: flex;
justify-content: space-between;
margin-top: 0.6rem;
}
.carousel__arrow {
width: 2rem;
height: 2rem;
border: 1px solid var(--border-strong);
border-radius: 50%;
background: none;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.carousel__dots {
display: flex;
justify-content: center;
gap: 0.4rem;
margin-top: 0.7rem;
}
.carousel__dot {
width: 0.7rem;
height: 0.7rem;
padding: 0;
border: 1px solid var(--border-interactive);
border-radius: 50%;
background: none;
cursor: pointer;
}
.carousel__dot[aria-selected="true"] {
border-color: var(--accent, var(--color-primary));
background: var(--accent, var(--color-primary));
}
.carousel__dot:focus-visible,
.carousel__arrow:focus-visible,
.carousel__play:focus-visible {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: 2px;
}
// Demo that subscribes to carousel events (consumer-side JS).
//
// The core controller (stimeo--carousel) handles slide advance, autoplay, and state
// sync, firing stimeo--carousel:change on slide change and stimeo--carousel:play /
// :pause when autoplay starts/stops. Here we only subscribe to those and show the
// current position and play state in a live region. Layout and transitions are CSS.
//
// For the bilingual catalog the copy isn't hardcoded: it uses the localized template
// the ERB passes (the "{position}" token in data-template and data-playing-suffix),
// and JS only fills in the position (number) and play state.
document.querySelectorAll('[data-controller~="stimeo--carousel"]').forEach((carousel) => {
const status = carousel.querySelector('[data-carousel-status]');
if (!status) return;
const template = status.dataset.template || '{position}';
const playingSuffix = status.dataset.playingSuffix || '';
const total = carousel.querySelectorAll('[data-stimeo--carousel-target="slide"]').length;
let position = `1 / ${total}`;
let playing = false;
const render = () => {
status.textContent = template.replace('{position}', position) + (playing ? playingSuffix : '');
};
carousel.addEventListener('stimeo--carousel:change', (event) => {
position = `${event.detail.index + 1} / ${event.detail.total}`;
render();
});
carousel.addEventListener('stimeo--carousel:play', () => {
playing = true;
render();
});
carousel.addEventListener('stimeo--carousel:pause', () => {
playing = false;
render();
});
render();
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--carousel"
Targets
| Name | Description | Attribute |
|---|---|---|
slide
required
|
A single slide panel; only the active one is visible and in focus order. | data-stimeo--carousel-target="slide" |
viewport
|
The container that holds the slides. | data-stimeo--carousel-target="viewport" |
prev
|
The button that moves to the previous slide. | data-stimeo--carousel-target="prev" |
next
|
The button that moves to the next slide. | data-stimeo--carousel-target="next" |
picker
required
|
A tab control that selects its corresponding slide; carries aria-selected and the roving tabindex. |
data-stimeo--carousel-target="picker" |
playToggle
|
The play/pause button whose aria-pressed mirrors the autoplay state. |
data-stimeo--carousel-target="playToggle" |
Values
| Name | Description | Attribute |
|---|---|---|
autoplay
|
Whether autoplay starts on connect (default false). | data-stimeo--carousel-autoplay-value |
interval
|
The autoplay interval in milliseconds (default 5000). | data-stimeo--carousel-interval-value |
loop
|
Whether prev/next wrap around the ends (default true). | data-stimeo--carousel-loop-value |
Actions
| Name | Description | Action |
|---|---|---|
goto
|
Jumps to the slide whose picker was activated. | stimeo--carousel#goto |
next
|
Advances to the next slide. | stimeo--carousel#next |
onPickerKeydown
|
Roving picker keys: arrows move focus only, Home/End activate first/last slide. | stimeo--carousel#onPickerKeydown |
pause
|
Suspends autoplay; hover is a temporary pause, focus is a hard stop. | stimeo--carousel#pause |
prev
|
Returns to the previous slide. | stimeo--carousel#prev |
resume
|
Lifts a hover pause and resumes autoplay if still on (focusout does nothing). | stimeo--carousel#resume |
togglePlay
|
Toggles autoplay on the user's explicit request. | stimeo--carousel#togglePlay |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched when the active slide changes; detail carries index and total. | stimeo--carousel:change |
pause
|
Dispatched when the autoplay timer stops. | stimeo--carousel:pause |
play
|
Dispatched when the autoplay timer starts. | stimeo--carousel:play |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-state |
Slide | "active" / "inactive". |
hidden |
Inactive slide | Hidden and removed from the focus order. |
aria-selected |
Picker (tab) | true only on the current slide's picker. |
tabindex |
Picker (tab) | 0 on the selected picker, -1 on the rest (roving). |
aria-pressed |
Play toggle | true while autoplay is running. |