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 it does not start by itself, and the play button still starts it. 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 (derived from the autoplay Value, whether a slide
is left to rotate to, and the reduced-motion preference) and the hidden of the
two glyph halves: ❚❚ shows while autoplay runs, ▶ while it is stopped. The
icons are decorative; the accessible name comes from the aria-label above and
stays the same in both states. %>
<span aria-hidden="true" data-stimeo--carousel-target="offLabel">▶</span>
<span aria-hidden="true" hidden data-stimeo--carousel-target="onLabel">❚❚</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" data-stimeo--carousel-target="pickerList"
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
hover and focus suspensions are understood as intentional (WCAG 2.2.2).
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, the play toggle's aria-pressed, and the hidden of the
* toggle's two glyph halves. The styling is 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);
}
.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 suspensions on hover and 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" |
pickerList
|
Optional container that lays the pickers out. Its computed direction decides which horizontal arrow moves to the next picker; without it the controller element is read. | data-stimeo--carousel-target="pickerList" |
playToggle
|
The play/pause button. Its aria-pressed says whether the autoplay intent is on and the carousel can rotate. Required when autoplay is on. |
data-stimeo--carousel-target="playToggle" |
onLabel
|
The half of a play toggle shown while autoplay runs; the controller owns its hidden. Resolved per toggle, decorative beside the button's own label. |
data-stimeo--carousel-target="onLabel" |
offLabel
|
The half of a play toggle shown while autoplay is stopped; the controller owns its hidden. Resolved per toggle, decorative beside the button's own label. |
data-stimeo--carousel-target="offLabel" |
Values
| Name | Description | Attribute |
|---|---|---|
autoplay
|
The autoplay intent, and the single source of truth for it — togglePlay writes back here, and nothing else does: having nowhere to go or a reduced-motion preference leaves the value as written (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. detail.reason reports user / focus / pointer / api / timeout. |
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; { index, total } without reason. |
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 and the carousel can rotate; false while there is nowhere to go, or while a reduced-motion preference holds the autostart back. 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. |