Carousel
Horizontal slide track with native scroll-snap. Per the answered design question recorded with this component: minimal scope — scroll-snap, prev/next buttons, pagination dots. No autoplay, no loop, no fade in v1.0 (deferred to v1.1).
When to use
- Product galleries, testimonial slides, feature highlights.
- Any short ordered set of content that benefits from horizontal layout with a "swipe / next" affordance.
If the content is a long list with arbitrary scroll, prefer a regular horizontally-scrolling container without the carousel chrome.
Anatomy
<section class="carousel" aria-roledescription="carousel">
<div class="carousel__viewport">
<div class="carousel__track" tabindex="0" aria-label="Slides">
<article class="carousel__slide" aria-roledescription="slide" aria-label="1 of 3">…</article>
<article class="carousel__slide" aria-roledescription="slide" aria-label="2 of 3">…</article>
<article class="carousel__slide" aria-roledescription="slide" aria-label="3 of 3">…</article>
</div>
</div>
<div class="carousel__controls">
<button type="button" class="carousel__prev" aria-label="Previous slide">‹</button>
<ol class="carousel__dots" role="tablist" aria-label="Slide navigation">
<li><button type="button" class="carousel__dot" aria-selected="true" aria-label="Go to slide 1"></button></li>
<li><button type="button" class="carousel__dot" aria-selected="false" aria-label="Go to slide 2"></button></li>
<li><button type="button" class="carousel__dot" aria-selected="false" aria-label="Go to slide 3"></button></li>
</ol>
<button type="button" class="carousel__next" aria-label="Next slide">›</button>
</div>
</section>
The runtime (initCarousel, auto-registered for .carousel) syncs
aria-selected on dots and disabled on prev/next buttons in
response to scroll position. Clicking prev/next/dot triggers a
smooth scroll on the track.
The native scroll-snap and overflow-x give touch swipe, mouse drag
(on supporting browsers), and keyboard scroll for free when the track
has tabindex="0".
Tokens used
From semantic tier
--color-surface-raised,--color-surface-canvas— button surface--color-ink-strong— button text--color-border-default,--color-border-strong— button border + dot--color-accent— focus ring, active dot--space-gap-elements-{s,m}— gaps--space-inset-element-s— dot size--size-control-m— prev/next button size--border-width-hairline/--border-width-focus--motion-fast,--motion-easing-default
Component-tier (generated)
--carousel-slide-min— minimum slide width (default18rem)
Accessibility
- The outer
<section>carriesaria-roledescription="carousel". - Each slide carries
aria-roledescription="slide"and anaria-labeldescribing position. - The dot row is a
role="tablist"witharia-label. Active dot hasaria-selected="true". - Prev/next buttons have
aria-label="Previous slide"/"Next slide"and becomedisabledat the ends. - The track has
tabindex="0"so keyboard users can scroll it with arrow keys (native overflow behavior).
For full keyboard navigation (Left/Right arrows step slides programmatically), pair with a small inline script — the runtime does not bind keyboard events itself (browsers already handle arrow-key scrolling on the focused track).
Edge cases
- Variable-width slides:
--carousel-slide-minprovides the per-slide minimum; auto-fit grid means slides may grow wider on large viewports. - Single slide: prev/next both stay disabled, the dot is solo
and
aria-selected="true". Visual is acceptable; consider not using a carousel for a single slide. - Reduced motion:
scroll-behavior: autooverridessmooth, so jumps are instant.
Do
- Use the full structure: viewport + track + controls.
- Provide
aria-labelon each slide describing what it shows. - Keep slide count small (3-7). Larger sets become tedious to navigate.
Don't
- Don't autoplay in v1.0 (not implemented; deferred to v1.1 per the answered design question).
- Don't override
overflow-xon the track — scroll-snap depends on it. - Don't put deeply interactive content in slides if the slides themselves are dragged horizontally — touch users can't reliably scroll past it.