Carousel
stimeo--carousel
A slideshow with autoplay you can stop, and slide pickers you can walk with the keyboard.
A slideshow following the WAI-ARIA Carousel pattern. Clicks and keys are handled for the carousel as a whole, so no slide needs setting up on its own, and a slide added later works the moment it appears. Slides that are not showing also leave the Tab order, so focus is never pulled into a link nobody can see. Add pickers and the keyboard walks them; leave them out and the previous and next buttons step through instead. Autoplay separates what you asked for from what merely suspends it: only the play button stops it for good, while hovering, focusing, or switching tabs pauses it and it resumes when you move away. For visitors who ask for less motion, autoplay is turned off outright. Buttons that lead nowhere say so, and the announcements stay quiet while it turns on its own.
For accessibility (WCAG 2.2.2) the play button is what stops autoplay for good. Hovering the carousel, moving focus into it, or switching to another tab only suspends the motion, so it never interferes with what you are doing, and it picks up again once you move away. That also means pressing play while the pointer is still over the carousel starts nothing until you move off it.
Keyboard
| Key | Action |
|---|---|
| Enter / Space | Activate a button (prev / next / play-pause / select a slide). |
| → / ← | Move focus to the next / previous slide picker (roving). Under RTL the two swap. |
| Home / End | Move focus to the first / last slide picker. Selection follows Enter or Space, not the key itself; a modified Home/End (Control+Home and friends) is left to the browser. |
<%# Markup for the carousel demo.
The library handles slide advance, autoplay, syncing the current slide's
data-state / hidden / inert, the pickers' (tabs) aria-selected and roving, the
aria-disabled of controls that cannot be reached, and the viewport's aria-live.
There is no data-action anywhere: the controller delegates clicks, picker keys,
hover, and focus from its own element, so the markup only declares targets.
Autoplay deliberately demonstrates WCAG 2.2.2: the play toggle is what stops it for
good, while hovering, focusing, or backgrounding the tab only suspends it until that
condition ends. This 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">
<div class="carousel__bar">
<button
type="button"
class="carousel__play"
aria-label="<%= t("components.carousel.demo.autoplay") %>"
data-stimeo--carousel-target="playToggle">
<%# The library owns aria-pressed (the autoplay Value is the intent it mirrors);
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>
<%# A visual read-out only. The library makes the viewport the live region, so a
second one here would make a screen reader announce the change twice. %>
<p
class="carousel__status"
data-carousel-status
data-template="<%= t("components.carousel.demo.status_template") %>"
data-playing-suffix="<%= t("components.carousel.demo.status_playing") %>"></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 inert" 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">‹</button>
<button type="button" class="carousel__arrow"
aria-label="<%= t("components.carousel.demo.next") %>"
data-stimeo--carousel-target="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"></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;
}
/* A fixed dark accent, not the theme-aware `--accent-700`: this text sits on
`--vital-100`, a raw ramp value that stays light in **both** themes, so a token that
lightens for dark would fail there (measured 2.5:1). */
.carousel__play[aria-pressed="true"] {
border-color: var(--accent, var(--color-primary));
background: var(--vital-100);
color: var(--vital-700);
}
/* 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);
}
/* The library writes aria-disabled on a control that has nowhere left to go: the
play toggle of a carousel that cannot rotate, and the arrow a non-looping
carousel has run out of slides for. The control keeps its focus (that is why it
is not natively disabled), so only its appearance changes. */
.carousel__play[aria-disabled="true"],
.carousel__arrow[aria-disabled="true"] {
opacity: 0.45;
cursor: default;
}
/* 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 carry both hidden and inert, 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;
}
/* The dots are tiny, so make the focused one pop — this makes arrow-key roving between
pickers (focus moves; Enter/Space or click then switches the slide) clearly visible. */
.carousel__dot:focus-visible {
outline-offset: 3px;
transform: scale(1.4);
border-color: var(--accent, var(--color-primary));
}
// 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, :reconcile when the slide set
// itself moves the position or the total, and :play / :pause when autoplay starts and
// stops. Here we subscribe to those and show the current position and play state.
//
// The events are edges, so the starting values come from the state hooks instead:
// data-state on the root says whether it is rotating right now, and data-state on
// the slides says which one is showing. That is what makes the read-out correct
// straight after a Turbo restore, where the controller reconnects — and fires its
// event — before this module runs again.
//
// The read-out is visual only: the controller makes the viewport the live region,
// so a second one here would announce every change twice. Layout is 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 || '';
let position = '';
// Read the current run state rather than waiting for the next play/pause edge:
// after a Turbo restore the controller has already reconnected (and already
// fired the event) by the time this module subscribes.
let playing = carousel.dataset.state === 'playing';
// Read the starting position out of the state hooks rather than assuming the first
// slide: a Turbo Drive restore hands back the slide the reader was actually on.
const readPosition = () => {
const slides = [...carousel.querySelectorAll('[data-stimeo--carousel-target="slide"]')];
const index = slides.findIndex((slide) => slide.dataset.state === 'active');
position = `${(index === -1 ? 0 : index) + 1} / ${slides.length}`;
};
const render = () => {
status.textContent = template.replace('{position}', position) + (playing ? playingSuffix : '');
};
const showDetail = (event) => {
position = `${event.detail.index + 1} / ${event.detail.total}`;
render();
};
carousel.addEventListener('stimeo--carousel:change', showDetail);
carousel.addEventListener('stimeo--carousel:reconcile', showDetail);
carousel.addEventListener('stimeo--carousel:play', () => {
playing = true;
render();
});
carousel.addEventListener('stimeo--carousel:pause', () => {
playing = false;
render();
});
readPosition();
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. Required. | data-stimeo--carousel-target="slide" |
viewport
|
The container that holds the slides; the controller makes it the live region. | data-stimeo--carousel-target="viewport" |
prev
|
The button that moves to the previous slide. Pair it with next. | data-stimeo--carousel-target="prev" |
next
|
The button that moves to the next slide. Pair it with prev. | data-stimeo--carousel-target="next" |
picker
|
A tab control that selects its corresponding slide; carries aria-selected and the roving tabindex. Optional — without pickers the slides are groups rather than tabpanels. |
data-stimeo--carousel-target="picker" |
playToggle
|
The play/pause button whose aria-pressed mirrors the autoplay intent. Required when autoplay is on. |
data-stimeo--carousel-target="playToggle" |
Values
| Name | Description | Attribute |
|---|---|---|
autoplay
|
The autoplay intent, and the single source of truth for it — togglePlay writes back here (default false). | data-stimeo--carousel-autoplay-value |
interval
|
The autoplay interval in milliseconds; must be finite and greater than 0, or it falls back to the default (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, Home, and End move focus only. | stimeo--carousel#onPickerKeydown |
pause
|
Suspends the rotation — as a focus suspension for a focus event, otherwise as a pointer one. The intent is untouched. | stimeo--carousel#pause |
prev
|
Returns to the previous slide. | stimeo--carousel#prev |
resume
|
Lifts the matching suspension; a focus move between the carousel's own controls keeps it. | stimeo--carousel#resume |
togglePlay
|
Flips the autoplay intent on the user's explicit request and writes it back to the Value. | stimeo--carousel#togglePlay |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched when the reader changes the active slide, including a tick of the autoplay they started; 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 |
reconcile
|
Fires when the controller re-derives the position itself — a slide or picker came or went, or a retained element's state attributes were rewritten in place — and the index or total moved; same detail as change. | stimeo--carousel:reconcile |
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 |
Root element | "playing" / "paused" — whether it is rotating right now. The play/pause events are edges, so a consumer that subscribes after connect (every consumer, after a Turbo restore) reads the current value from here. |
data-state |
Slide | "active" / "inactive". |
hidden + inert |
Inactive slide | Hidden and removed from the focus order. inert holds that even when your CSS overrides display to lay the slides out as a track. |
aria-selected |
Picker (tab) | "true" on the picker for the visible slide, "false" on the others. You can render the initial one server-side; if several are marked, the first wins. |
tabindex |
Picker (tab) | 0 on the selected picker, -1 on the rest (roving). |
aria-pressed |
Play toggle | true while the autoplay intent is on. Hovering, focusing, or a hidden tab suspends the rotation without changing it. |
aria-disabled |
Play toggle, prev, next | true on a control with nowhere left to go — the toggle of a carousel that cannot rotate, or the arrow a non-looping carousel has run out of slides for. The attribute is removed again once the control is reachable. |
aria-live |
Viewport | "off" while the carousel rotates, "polite" while it is stopped, with aria-atomic="false" so only the new slide is read. |