Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Deploy in seconds
Roll back instantly
Twenty-four regions
Secrets at rest
1 of 4

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.

With peek
Deploy in seconds
Roll back instantly
Twenty-four regions
Secrets at rest
1 of 4
Full widthNothing says there is more
Deploy in seconds
1 of 4

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.

Deploy in seconds
1 of 4

Hover or focus the carousel and the rotation stops.

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.

A scroll row: no dots, no arrows, no state — just overflow and snap.
Deploy in seconds
Roll back instantly
Twenty-four regions
Secrets at rest
Deploy in seconds
Roll back instantly
Twenty-four regions
Secrets at rest

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.

Slide 1
Frame
Prev
Next
At the end
Dot idle
Dot active
Playing
Paused
2 of 4
Counter

Anatomy

Every part, every measurement, and the reason it is that number.

Deploy in seconds
Roll back instantly
Twenty-four regions
Secrets at rest
1 of 4

A snapping scroll track, always-visible arrows, dots that indicate position, and a live counter that says where you are.

  1. Trackscroll-snap-type: x mandatory

    A scroll container, not a transform. The browser supplies momentum, touch physics and reduced-motion handling for nothing.

  2. Peek80% frame width

    The sliver of the next frame is the affordance. Without it, nothing on screen says there is more than one.

  3. Gap12px between frames

    Enough that frames read as separate objects. Zero makes a set of cards read as one wide image.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-3Gap between frames

Radius

TokenValueUsed for
--radius-lgFrame corners

Shadow

TokenValueUsed for
--shadow-e2—Arrows, so they read above the content

Motion

TokenValueUsed for
scroll-behaviorProgrammatic movement between frames
autoplay intervalIf autoplay exists at all

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightLabel gapMin widthMax widthTouch targetWhen to use
FrameContent-driven—80% with peek——Peek at 80% is the standard. 100% removes the affordance entirely.
Arrow32px—32px—44px on coarse pointersAlways visible, disabled at the ends, never removed.
Dot8px———24px targetThe 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.

Do

overflow-x: auto
scroll-snap-type: x mandatory
Build it on scroll-snapThe browser gives you momentum, touch physics, keyboard scrolling and reduced-motion handling. A transform-based carousel reimplements all of it and gets some wrong.
Deploy in seconds
Roll back instantly
Twenty-four regions
Secrets at rest
1 of 4
Show a sliver of the next frameThe peek is the only thing that says "there is more". Dots are a fallback most people never look at.
Keep arrows visible and disable at the endsHover-revealed arrows do not exist on touch. Removing them at the ends shifts the layout at exactly the moment the user reaches the boundary.
2 of 4
Announce the position"2 of 4" in a live region is the only feedback a non-visual user gets that anything moved, and the only exact readout for everyone else.

Don't

setInterval(next, 3000) · no pause · no hover stop
Do not autoplay without a pause controlIt fails WCAG 2.2.2 outright, and it moves content while people are reading it. If it must autoplay, it must also stop.
seenrarelynever
Do not put important content past frame oneThe evidence is consistent: later frames are seen by a small fraction of visitors. If it matters, it does not belong in a carousel.
Do not use dots past about six framesNobody counts fourteen dots, and each one is a 24px target for a one-in-fourteen chance of being useful. Use the counter alone.
Three competing headlines, rotating
Do not rotate a marketing heroA rotating hero consistently underperforms a single strong message. Each slide dilutes the one before it and none of them get read.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.13Content on Hover or FocusAA2.1.1KeyboardA2.2.2Pause, Stop, HideA2.5.7Dragging MovementsAA4.1.3Status MessagesAA

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

TabReaches 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 / EndJumps to the first or last frame.
SpaceToggles 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.
AttributeApplied toNotes
aria-roledescription="carousel"The containerWith aria-label naming its purpose. It tells assistive tech this is a rotating region before anything moves.
aria-roledescription="slide"Each frameWith aria-label "2 of 4". Position is the single most useful thing a frame can announce.
aria-live="polite"The counterOff while autoplay is running — an auto-advancing live region is a screen reader talking over the user.
role="tablist" / "tab"The dotsWith aria-selected. Each dot needs a real label; a bare dot announces as an unnamed button.
aria-hiddenOff-screen framesOnly when they are genuinely not visible. With peek, the next frame is partly visible and must not be hidden.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
aria-label*string—Names the set. A carousel with no label is an unexplained rotating region.
peekbooleantrueShows a sliver of the next frame. Turning it off removes the only affordance most users notice.
autoplaybooleanfalseRequires a visible pause control. Stops on hover and focus, and permanently once the user interacts.
intervalnumber5000Milliseconds. Below 5000 the content cannot be read before it moves.
dotsbooleantrueHide past about six frames — nobody counts fourteen dots.
loopbooleanfalseOff by default. A looping track removes the only signal that the user has seen everything.

Notes

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.