Carousel
Sequential content shown a frame at a time, with the accessibility and engagement debt that always comes attached.
Also called Slideshow, Content Slider, Coverflow — in this system all of them are Carousel.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Peek is the affordance
A sliver of the next frame is what tells the user there is more. Full-width frames with no peek rely entirely on the dots, which most people never look at.
Autoplay needs a pause control
WCAG 2.2.2 requires it for anything that moves for more than five seconds. It must also stop on hover and on focus, and stay stopped once the user takes over.
What to build instead
Most carousels exist because a grid did not fit. On a narrow screen a horizontally scrollable row of cards does the same job with none of the machinery.
Dots are indicators, not navigation
Past about six frames the dots stop being countable and start being decoration. At that point a scroll row with no indicator is more honest.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
A snapping scroll track, always-visible arrows, dots that indicate position, and a live counter that says where you are.
- Trackscroll-snap-type: x mandatory
A scroll container, not a transform. The browser supplies momentum, touch physics and reduced-motion handling for nothing.
- Peek80% frame width
The sliver of the next frame is the affordance. Without it, nothing on screen says there is more than one.
- Gap12px between frames
Enough that frames read as separate objects. Zero makes a set of cards read as one wide image.
- Arrows32px, always visible
Never hover-revealed: hover does not exist on touch, which is where carousels are used most. Disabled at the ends rather than removed.
- Dot8px dot, 24px target
The target is padding around the dot. An 8px hit area is unusable, and growing the dot makes the row look like a control panel.
- Counter"2 of 4", aria-live
The only feedback a non-visual user gets that the frame changed, and the only exact position readout for everyone else.
- Autoplay interval≥5s, pause on hover
Anything faster is unreadable. Under WCAG 2.2.2, anything moving for more than five seconds needs a pause control.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-accent | — | The active dot |
| --ds-border-strong | — | Inactive dots |
| --ds-surface-raised | — | Elevated arrow buttons over content |
| --ds-fg-muted | — | The position counter |
| --ds-focus-ring | — | Focus outline on arrows and dots |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-3 | Gap between frames |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Frame corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e2 | — | Arrows, so they read above the content |
Motion
| Token | Value | Used for |
|---|---|---|
| scroll-behavior | Programmatic movement between frames | |
| autoplay interval | If autoplay exists at all |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Label gap | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|
| Frame | Content-driven | — | 80% with peek | — | — | Peek at 80% is the standard. 100% removes the affordance entirely. |
| Arrow | 32px | — | 32px | — | 44px on coarse pointers | Always visible, disabled at the ends, never removed. |
| Dot | 8px | — | — | — | 24px target | The target is padding. Growing the dot itself makes the row read as a control panel. |
| Dot row | — | — | — | 6 dots | — | Past six, switch to a counter alone — nobody counts fourteen dots. |
| Gap | — | 12px | — | — | — | Between frames, so a set of cards does not read as one wide image. |
overflow-x: auto
scroll-snap-type: x mandatoryNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Arrows sit over arbitrary content, so they need their own surface — an elevated button, not a bare glyph over a photograph.
- The active dot must differ from the inactive ones by more than opacity; at 8px a subtle difference is invisible.
- Any text over a media frame needs a scrim or a solid panel behind it. Contrast against an unknown image is not something you can assert.
- The counter is content and owes 4.5:1.
Keyboard
| Tab | Reaches the previous and next buttons, the dots, and any interactive content inside the current frame. |
| ← / → | Moves between frames when focus is on the dot group. Also scrolls the track natively when it has focus. |
| Home / End | Jumps to the first or last frame. |
| Space | Toggles autoplay when focus is on the play/pause control. |
Screen readers
- Announce position on change: "Slide 2 of 4". Nothing else about the movement is useful.
- Turn the live region off while autoplay is running. A region that announces every five seconds is a screen reader talking over its user.
- Off-screen frames must be inert. A user tabbing into an invisible frame has no way to understand where they are.
Focus & touch
- Moving to a frame must not steal focus from whatever the user was doing. Interactive content inside an off-screen frame must be removed from the tab order — otherwise Tab scrolls the track sideways to something the user cannot see, which is deeply disorienting.
- Swipe is the primary interaction here and scroll-snap gives it to you correctly. Arrows must still exist for WCAG 2.5.7 — swipe cannot be the only path. Keep the peek: it is even more important on a phone, where the dots are small and easily missed. Never trap vertical scrolling inside a horizontal carousel; scroll-snap on one axis leaves the other alone.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-roledescription="carousel" | The container | With aria-label naming its purpose. It tells assistive tech this is a rotating region before anything moves. |
| aria-roledescription="slide" | Each frame | With aria-label "2 of 4". Position is the single most useful thing a frame can announce. |
| aria-live="polite" | The counter | Off while autoplay is running — an auto-advancing live region is a screen reader talking over the user. |
| role="tablist" / "tab" | The dots | With aria-selected. Each dot needs a real label; a bare dot announces as an unnamed button. |
| aria-hidden | Off-screen frames | Only when they are genuinely not visible. With peek, the next frame is partly visible and must not be hidden. |
Example usage
1import { Carousel, CarouselSlide } from '@/ui/Surface'23<Carousel aria-label="Product highlights" peek>4 {slides.map((s) => (5 <CarouselSlide key={s.id}>{s.content}</CarouselSlide>6 ))}7</Carousel>89// Scroll-snap does the work. The buttons are a thin layer over a scroll10// container, not a bespoke transform engine.11function go(index: number) {12 const track = trackRef.current!13 track.scrollTo({14 left: index * track.clientWidth * PEEK_RATIO,15 behavior: 'smooth',16 })17}1819// Read the position back FROM the scroll, so a swipe and a button press end20// up in the same state.21function onScroll() {22 const track = trackRef.current!23 setIndex(Math.round(track.scrollLeft / (track.clientWidth * PEEK_RATIO)))24}2526// Autoplay: stops on hover, stops on focus, and stays stopped once the user27// takes over. Anything less fails WCAG 2.2.2.28React.useEffect(() => {29 if (!playing || userInteracted) return30 const id = setInterval(next, 5000)31 return () => clearInterval(id)32}, [playing, userInteracted])3334// Off-screen frames must leave the tab order, or Tab scrolls sideways to35// something the user cannot see.36<div inert={!isVisible}>{slide.content}</div>Framework-free HTML
<section aria-roledescription="carousel" aria-label="Product highlights">
<div class="ds-carousel__track">
<div role="group" aria-roledescription="slide" aria-label="1 of 4">…</div>
<!-- With peek the next frame is partly visible, so it must NOT be hidden. -->
<div role="group" aria-roledescription="slide" aria-label="2 of 4">…</div>
<div role="group" aria-roledescription="slide" aria-label="3 of 4" inert>…</div>
</div>
<!-- Always visible: hover does not exist on touch. -->
<button type="button" aria-label="Previous slide" disabled>‹</button>
<button type="button" aria-label="Next slide">›</button>
<div role="tablist" aria-label="Choose a slide">
<button type="button" role="tab" aria-selected="true" aria-label="Slide 1 of 4"></button>
<button type="button" role="tab" aria-selected="false" aria-label="Slide 2 of 4"></button>
</div>
<!-- Turn this off while autoplay is running. -->
<p role="status" aria-live="polite">Slide 1 of 4</p>
</section>CSS
.ds-carousel__track {
display: flex;
gap: 12px;
overflow-x: auto;
/* The browser supplies momentum, touch physics and reduced-motion
handling. A transform-based carousel reimplements all of it. */
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
}
.ds-carousel__track::-webkit-scrollbar { display: none; }
.ds-carousel__slide {
flex: 0 0 auto;
/* The peek IS the affordance. 100% and nothing says there is more. */
inline-size: 80%;
scroll-snap-align: start;
}
/* Over arbitrary content, so they need their own surface — not a bare
glyph over a photograph. */
.ds-carousel__arrow {
position: absolute;
inset-block-start: 50%;
translate: 0 -50%;
inline-size: 32px;
block-size: 32px;
background: var(--ds-surface-raised);
box-shadow: var(--shadow-e2);
}
/* The dot is 8px; the TARGET is 24px. Padding, not size. */
.ds-carousel__dot {
inline-size: 24px;
block-size: 24px;
display: grid;
place-items: center;
}
.ds-carousel__dot::before {
content: '';
inline-size: 8px;
block-size: 8px;
border-radius: 999px;
background: var(--ds-border-strong);
}
.ds-carousel__dot[aria-selected='true']::before { background: var(--ds-accent); }
@media (prefers-reduced-motion: reduce) {
.ds-carousel__track { scroll-behavior: auto; }
}
@media (pointer: coarse) {
.ds-carousel__arrow { inline-size: 44px; block-size: 44px; }
}Component API
Carousel
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label* | string | — | Names the set. A carousel with no label is an unexplained rotating region. |
| peek | boolean | true | Shows a sliver of the next frame. Turning it off removes the only affordance most users notice. |
| autoplay | boolean | false | Requires a visible pause control. Stops on hover and focus, and permanently once the user interacts. |
| interval | number | 5000 | Milliseconds. Below 5000 the content cannot be read before it moves. |
| dots | boolean | true | Hide past about six frames — nobody counts fourteen dots. |
| loop | boolean | false | Off by default. A looping track removes the only signal that the user has seen everything. |
Professional tips
- Before building one, check whether a horizontally scrollable row of cards would do. It usually would, with no dots, no arrows and no state to manage.
- Do not loop by default. Reaching the end is the only signal a user gets that they have seen everything, and looping removes it.
- Read the index back from the scroll position rather than tracking it separately, so a swipe and a button press cannot disagree.
- Lazy-load frames beyond the next one, but never lazy-load the first — it is the one almost everyone sees.
- If the frames are links or cards, make the whole frame the target rather than a small button inside it.
Performance
- Scroll-snap runs on the compositor. A JavaScript transform carousel runs on the main thread and stutters exactly when the page is busiest.
- Debounce the scroll handler that derives the index. It fires far more often than the frame actually changes.
- Use content-visibility: auto on off-screen frames so their layout is skipped until they scroll in.
- Clear the autoplay timer on unmount and on every user interaction. An orphaned interval scrolling a removed element is a memorable bug.
Common mistakes
- Autoplay with no pause control, failing WCAG 2.2.2.
- Hover-revealed arrows, which do not exist on touch.
- No peek, so nothing indicates there is more than one frame.
- Off-screen frames left in the tab order, so Tab scrolls sideways to invisible content.
- Dots as the only position indicator past six frames.
- A transform-based implementation that loses momentum, touch physics and reduced-motion handling.
- Important content on slide three, which almost nobody sees.
- Arrows removed at the ends, shifting the layout at the boundary.
Real-world recommendations
- Carousels on marketing pages have been measured repeatedly and the finding does not change: the first frame gets the clicks and the rest get almost none. Put your best message there and stop.
- Where they genuinely work is media browsing on a phone — a product gallery, a photo set — because swiping is natural and the order does not carry meaning.
- Content rows in streaming interfaces are carousels that stopped pretending: no dots, no autoplay, just a scroll row with peek. That is usually the right design.
- If a stakeholder wants a rotating hero, ask which message is strongest and ship that one. The carousel is nearly always a way of avoiding that decision.