Carousel
A carousel built the platform’s way: the track is a native CSS scroll-snap
strip, so it scrolls and snaps with zero JavaScript — by touch, trackpad,
scrollbar, and (because the track is a focusable scroll region) the arrow keys.
The optional enhanceCarousel behavior
back-fills the discoverable controls: prev/next buttons, a dot strip, active
tracking, and inerting of off-screen slides.
<div class="re-carousel frame" data-re-carousel>
<div
class="re-carousel__track"
role="group"
aria-roledescription="carousel"
aria-label="Featured photos"
tabindex="0"
>
<figure
class="re-carousel__slide"
aria-roledescription="slide"
aria-label="Sunset over the bay"
>
<div class="slide-art" style="background-color: var(--re-color-accent-600)">1</div>
<figcaption>Sunset over the bay</figcaption>
</figure>
<figure
class="re-carousel__slide"
aria-roledescription="slide"
aria-label="Forest trail in autumn"
>
<div class="slide-art" style="background-color: var(--re-color-success-600)">2</div>
<figcaption>Forest trail in autumn</figcaption>
</figure>
<figure
class="re-carousel__slide"
aria-roledescription="slide"
aria-label="City skyline at dusk"
>
<div class="slide-art" style="background-color: var(--re-color-warning-600)">3</div>
<figcaption>City skyline at dusk</figcaption>
</figure>
<figure
class="re-carousel__slide"
aria-roledescription="slide"
aria-label="Desert dunes at noon"
>
<div class="slide-art" style="background-color: var(--re-color-danger-600)">4</div>
<figcaption>Desert dunes at noon</figcaption>
</figure>
</div>
</div> Autoplay
Section titled “Autoplay”Add data-re-carousel-autoplay (optionally ="3000" for the interval in ms,
default 5000) to auto-advance. The behavior injects a Pause/Play button — the
WCAG 2.2.2 stop mechanism — and pauses on hover, on focus within the carousel, and
when the tab is hidden. Under prefers-reduced-motion: reduce it starts paused.
It works on either control rung (the timer + pause button are JS regardless of who
draws the dots).
<div class="re-carousel frame" data-re-carousel data-re-carousel-autoplay="3000">
<div
class="re-carousel__track"
role="group"
aria-roledescription="carousel"
aria-label="Product highlights (auto-advancing)"
tabindex="0"
>
<figure class="re-carousel__slide" aria-roledescription="slide" aria-label="Fast">
<div class="slide-art" style="background-color: var(--re-color-accent-600)">A</div>
<figcaption>Fast</figcaption>
</figure>
<figure class="re-carousel__slide" aria-roledescription="slide" aria-label="Accessible">
<div class="slide-art" style="background-color: var(--re-color-success-600)">B</div>
<figcaption>Accessible</figcaption>
</figure>
<figure class="re-carousel__slide" aria-roledescription="slide" aria-label="Native">
<div class="slide-art" style="background-color: var(--re-color-warning-600)">C</div>
<figcaption>Native</figcaption>
</figure>
</div>
</div> How it’s layered
Section titled “How it’s layered”- Rung A (no JS) —
.re-carousel__trackisoverflow-x: auto+scroll-snap-type: inline mandatory. It already works: drag, swipe, scrollbar, and Arrow/Page/Home/End once the track has focus. This is the whole carousel for users without the behavior. - Rung C (
enhanceCarousel) — injects the buttons + dots and tracks the active slide. This is the accessibility-tested control path. - Rung B (CSS Carousel pseudo-elements) —
::scroll-button()/::scroll-markerdraw the controls with no JS at all. It’s shipped behind@supportsand active where the browser supports it (Chrome 135+ today);enhanceCarouselfeature-tests the same condition and stands down, so you never get both control sets. It’s labeled experimental: the UA markers/buttons are ahead of assistive tech (documented tab-exposure bugs), so Rung C stays the a11y-tested path and the manual SR pass is done against it.
Accessibility
Section titled “Accessibility”- The scroll region is focusable —
tabindex="0"on the track makes the native arrow-key scrolling reachable (a scrollable region must be keyboard-operable). The buttons are the primary, discoverable control; arrow-key scrolling is the secondary path. - Buttons, not tabs — prev/next and the dots are plain
<button>s witharia-label+aria-current(the active dot), not arole="tablist"— the scroll model isn’t a tabpanel. - Off-screen slides are inert — the behavior sets
inerton any slide that doesn’t overlap the track viewport (geometry-based, so it holds at any slide-per-view count) so Tab can’t land on a slide scrolled out of view (the classic carousel keyboard trap), and they leave the reading order. Prev/next at the ends usearia-disabled(not the nativedisabledproperty), so they stay focusable and announced instead of dropping focus to the page. - Settle announcement — a polite live region announces the settled slide (“Sunset over the bay (1 of 4)”), debounced to fire once per snap rather than on every scroll frame; pausing autoplay also announces where it stopped.
- Autoplay is stoppable — auto-advancing content needs a way to pause it (WCAG 2.2.2 Pause, Stop, Hide). The injected Pause/Play button is that mechanism; the carousel also pauses while hovered, while focus is inside it, and while the tab is hidden, and it announces nothing while playing (so the live region isn’t spammed). Under
prefers-reduced-motion: reduceit starts paused. - Reduced motion — programmatic scrolling drops to an instant jump under
prefers-reduced-motion: reduce(and the CSSscroll-behavior: smoothis already gated on it). - RTL — navigation uses
scrollIntoView({ inline })and active detection uses box geometry, so neither depends on the sign ofscrollLeft(which differs across engines). - Forced colors — the active dot is re-established with
Highlightand the buttons get a realButtonTextborder (their surface + shadow flatten away). - No custom event — derive the index from the native
scrollyourself if you need it. Dynamic slides are out of scope for now: re-runenhanceCarouselafter adding or removing slides.
See the accessibility guide for the project-wide approach.