Carousel

.carousel is a scroll-snap track with dots and arrows over it. What makes it interesting is that it has two implementations: a JavaScript one, and a native one built from ::scroll-marker and ::scroll-button — and where the browser has the native pseudo-elements, the markup does not change and the script steps aside.

When to use it

Use it for a set of peer items where seeing them all at once is not necessary and space is short: product photos, testimonials, a gallery. Be honest about the cost — carousels hide most of their content behind a gesture, and most readers never move past the first slide.

Drag it, or use the arrows. The dots are built for you.
<div class="carousel" data-deck-carousel> <div class="carousel-track"> <div class="carousel-slide"> <img alt="Slide 1" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23c7d6d4%22/%3E%3C/svg%3E"> </div> <div class="carousel-slide"> <img alt="Slide 2" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23d8cfc4%22/%3E%3C/svg%3E"> </div> <div class="carousel-slide"> <img alt="Slide 3" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23c4cdd8%22/%3E%3C/svg%3E"> </div> <div class="carousel-slide"> <img alt="Slide 4" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23d3d8c4%22/%3E%3C/svg%3E"> </div> </div> <button class="carousel-arrow carousel-prev" aria-label="Previous slide"> <svg class="icon mirror-rtl" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-left"></use></svg> </button> <button class="carousel-arrow carousel-next" aria-label="Next slide"> <svg class="icon mirror-rtl" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg> </button> <div class="carousel-dots"></div> </div>
<div class="carousel" data-deck-carousel>
  <div class="carousel-track">
    <div class="carousel-slide">
      <img alt="Slide 1" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23c7d6d4%22/%3E%3C/svg%3E">
    </div>
    <div class="carousel-slide">
      <img alt="Slide 2" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23d8cfc4%22/%3E%3C/svg%3E">
    </div>
    <div class="carousel-slide">
      <img alt="Slide 3" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23c4cdd8%22/%3E%3C/svg%3E">
    </div>
    <div class="carousel-slide">
      <img alt="Slide 4" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23d3d8c4%22/%3E%3C/svg%3E">
    </div>
  </div>
  <button class="carousel-arrow carousel-prev" aria-label="Previous slide">
    <svg class="icon mirror-rtl" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-left"></use></svg>
  </button>
  <button class="carousel-arrow carousel-next" aria-label="Next slide">
    <svg class="icon mirror-rtl" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
  </button>
  <div class="carousel-dots"></div>
</div>

.carousel-dots is left empty in the markup — deck-extras.js fills it with one button per slide. On a browser with ::scroll-marker it stays empty and hidden, because the browser draws the dots instead.

Two implementations, one markup

This is the most interesting thing in the component, and it is worth reading the source comment, which says it plainly:

::scroll-marker gives every slide its own dot and ::scroll-button gives the track its arrows, with the active state, the disabled state, the click handling and the arrow-key focus order all done by the browser.

Where the browser has them

@supports selector(::scroll-marker) {
  .carousel-dots, .carousel-arrow { display: none; }
  .carousel-track { scroll-marker-group: after; }
  .carousel-slide::scroll-marker { content: ""; … }
}

Deck's own dots and arrows are hidden, the track grows a marker group, and every slide contributes a dot. The active dot, the disabled arrow at each end, the click handling and the arrow-key focus order are all the browser's — nothing is wired, nothing is measured, and there is no scroll listener.

The scroll buttons take logical keywords — inline-start and inline-end rather than left and right — so an RTL carousel flips them with no rule in deck.rtl at all.

Where it does not

deck-extras.js builds the dots, wires the arrows, tracks the current slide by measuring which one is nearest the centre, sets aria-current on the dots and disabled on the end arrows, and fires a deck:slide event. It also checks matchMedia('(prefers-reduced-motion: reduce)') and scrolls with behavior: 'auto' when it matches.

The point is that the author writes the same markup either way. There is no @supports in the HTML, no feature detection in the page, and no difference in what a reader sees.

Peek and multi

Slides are flex-basis: 100% by default — one at a time. .carousel-peek makes them 86%, so the next slide is partly visible, which is the cue that tells a reader there is more. .carousel-multi shows three at a time above 40rem and 78% below it.

.carousel-peek — the sliver at the edge is the affordance
<div class="carousel carousel-peek" data-deck-carousel> <div class="carousel-track"> <div class="carousel-slide"> <img alt="Slide 1" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23c7d6d4%22/%3E%3C/svg%3E"> </div> <div class="carousel-slide"> <img alt="Slide 2" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23d8cfc4%22/%3E%3C/svg%3E"> </div> <div class="carousel-slide"> <img alt="Slide 3" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23c4cdd8%22/%3E%3C/svg%3E"> </div> <div class="carousel-slide"> <img alt="Slide 4" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23d3d8c4%22/%3E%3C/svg%3E"> </div> </div> <div class="carousel-dots"></div> </div>
<div class="carousel carousel-peek" data-deck-carousel>
  <div class="carousel-track">
    <div class="carousel-slide">
      <img alt="Slide 1" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23c7d6d4%22/%3E%3C/svg%3E">
    </div>
    <div class="carousel-slide">
      <img alt="Slide 2" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23d8cfc4%22/%3E%3C/svg%3E">
    </div>
    <div class="carousel-slide">
      <img alt="Slide 3" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23c4cdd8%22/%3E%3C/svg%3E">
    </div>
    <div class="carousel-slide">
      <img alt="Slide 4" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23d3d8c4%22/%3E%3C/svg%3E">
    </div>
  </div>
  <div class="carousel-dots"></div>
</div>

Peek is worth preferring over a plain carousel for exactly the reason the scroller page gives: the scrollbar is hidden, so the partially visible slide is the only persistent signal that the track scrolls at all.

Captions

.carousel-caption overlays text on a slide. It reads the shared --scrim token — an eased gradient rather than a flat wash, so it only darkens where the text sits instead of dulling the whole image — and the slide is its containing block, so each caption stays on its own slide.

<div class="carousel" data-deck-carousel> <div class="carousel-track"> <div class="carousel-slide"> <img alt="A quiet street" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%238fa8a4%22/%3E%3C/svg%3E"> <div class="carousel-caption">Nightly export, 04:12 UTC</div> </div> <div class="carousel-slide"> <img alt="A second view" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23a48f9a%22/%3E%3C/svg%3E"> <div class="carousel-caption">Retried twice before succeeding</div> </div> </div> <div class="carousel-dots"></div> </div>
<div class="carousel" data-deck-carousel>
  <div class="carousel-track">
    <div class="carousel-slide">
      <img alt="A quiet street" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%238fa8a4%22/%3E%3C/svg%3E">
      <div class="carousel-caption">Nightly export, 04:12 UTC</div>
    </div>
    <div class="carousel-slide">
      <img alt="A second view" src="data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22640%22 height=%22360%22%3E%3Crect width=%22640%22 height=%22360%22 fill=%22%23a48f9a%22/%3E%3C/svg%3E">
      <div class="carousel-caption">Retried twice before succeeding</div>
    </div>
  </div>
  <div class="carousel-dots"></div>
</div>

Autoplay

data-autoplay="5000" advances the carousel every five seconds. It pauses on pointerenter, on focusin, and when the tab is hidden — and it does not start at all if prefers-reduced-motion: reduce matches.

Those four conditions are the minimum for autoplay to be defensible, and even then it is worth asking whether it should exist. Content that moves on its own is content a reader has to chase, and WCAG asks that anything auto-updating for more than five seconds can be paused. Deck's pause triggers are hover and focus — a reader who is simply reading, with the pointer elsewhere, has no way to stop it.

Classes

Generated from src/22-nav.css by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.carousel deck.components Scroll snap does the work. Arrows and dots are progressive enhancement, so it still swipes correctly with JavaScript switched off. 22-nav.css:11
.carousel-arrow deck.components Previous and next. Hidden on a coarse pointer, where the reader swipes instead — so do not also hide the dots. 22-nav.css:47
.carousel-caption deck.components Text over a slide. The generous block-start padding is there to give the scrim room to fade — without a background the near-white text sits directly on the image and disappears over anything light. 22-nav.css:165
.carousel-dot deck.components the tap target is bigger than the dot 22-nav.css:78
.carousel-dots deck.components Sets display, justify-content, gap, padding-block-start. 22-nav.css:72
.carousel-multi deck.components No declarations. 22-nav.css:39
.carousel-next deck.components Sets inset-inline-end. 22-nav.css:69
.carousel-peek deck.components No declarations. 22-nav.css:38
.carousel-prev deck.components Sets inset-inline-start. 22-nav.css:68
.carousel-slide deck.components One slide. flex-basis: 100% by default; .carousel-peek makes it 86% so the next one is partly visible, which is the only persistent cue that the track scrolls once the scrollbar is hidden. 22-nav.css:28
.carousel-track deck.components The scroll-snap container holding the slides. 22-nav.css:14

Tokens

Design tokens this component reads
Token Live Declared value
--surface light-dark(var(--ink-0), var(--ink-950))
--brand-600 oklch(51.6% var(--chroma-brand) var(--hue-brand))
--ink-300 oklch(84.6% calc(var(--chroma-neutral) * 2.2) var(--hue-neutral))
--r-md 12px
--r-full 999px
--shadow-2 0 2px 4px oklch(20% .03 var(--shadow-color) / .05), 0 4px 12px oklch(20% .03 var(--shadow-color) / .07)
--space-2 .5rem
--space-3 .75rem
--space-4 1rem
--dur-2 190ms

Accessibility

  • The native path is more accessible than the scripted one, which is the argument for it. ::scroll-marker dots are real focusable controls with the browser's own arrow-key handling and focus order — behaviour that a hand-built dot row has to reimplement and usually gets partly wrong.
  • Arrows are hidden on touch. @media (pointer: coarse) { .carousel-arrow { display: none } } — a phone reader swipes instead. That is right for a pointer, and it means the dots are the only control left, so do not hide those too.
  • Dots have a bigger target than they look. .carousel-dot::before is a 32px invisible square centred on a 7px dot. Still under 44px, but far better than the visible size — and the technique is worth copying anywhere a control is deliberately small.
  • The current slide is announced through aria-current on the dot, which is also what styles it — the same attribute doing both jobs, as with tabs.
  • Slides are not hidden when off-screen, and should not be. They are scrolled, not hidden: everything is in the accessibility tree and the tab order, which is what lets a keyboard user reach slide four without pressing an arrow.
  • Give the carousel a label. aria-label on the .carousel, and consider aria-roledescription="carousel" — though be aware that the roledescription is announced on every interaction and can become noise.
  • Autoplay needs a pause control to satisfy WCAG properly. Hover and focus are not enough for a reader who is not touching anything. If you use autoplay, add a visible pause button.
  • Images need real alt text. A carousel of decorative images is decoration; a carousel of product photos is content, and each one needs describing.

Right to left

The track is a flex row that the browser reverses under dir="rtl", and the arrows are positioned with inset-inline-start and inset-inline-end, so they swap sides on their own. The chevron glyphs need .mirror-rtl — a chevron is directional, and mirroring it is the one thing the layout cannot do for you.

deck-extras.js checks getComputedStyle(track).direction === 'rtl' and swaps which arrow key moves which way, for the same reason a tablist has to.

On the native path none of that applies: ::scroll-button(inline-start) resolves to the correct edge by itself, which is why the source comment calls it out.

Reduced motion

Scrolling between slides is smooth by default and auto when prefers-reduced-motion: reduce matches — checked in JavaScript, because the scroll is initiated there. Autoplay does not start at all under the same condition.

The arrows also scale slightly on hover; that transition is collapsed to .01ms by the global reset.

A carousel is one of the few components where reduced motion changes behaviour rather than only duration. Autoplay is motion the reader did not ask for, so switching it off entirely is the correct response — not merely making it faster.

Printing

src/24-media.css drops .carousel-arrow and .carousel-dots from print — controls that cannot be operated on paper. The track itself is not unwound, so a printed carousel shows the slide that happened to be in view and drops the rest, the same gap as .scroller and with the same fix.

Recorded in FINDINGS.md alongside the scroller, since it is one rule covering both.

Overriding it

@layer app.components {
  /* Two slides at a time rather than three */
  .carousel-multi .carousel-slide { flex-basis: calc((100% - var(--space-4)) / 2); }

  /* Keep the arrows on touch */
  @media (pointer: coarse) { .carousel-arrow { display: flex; } }

  /* Style the native dots, where they exist */
  .carousel-slide::scroll-marker:target-current {
    background: var(--brand-600);
    inline-size: 22px;
  }
}

:target-current is the native equivalent of [aria-current="true"] on Deck's own dots. If you restyle one, restyle both, or the carousel looks different depending on the browser.

When not to use it

  • Not for anything important. Readers do not move past the first slide. If all the content matters, use a .grid and let it wrap.
  • Not as a homepage hero rotator. The most-studied carousel pattern there is, and the finding is consistent: almost nobody sees slide two.
  • Not for a row of small items. Use .scroller, which is the same scroll-snap idea without dots, arrows or a script.
  • Not with autoplay, unless you also add a pause button. Hover and focus do not cover a reader who is simply reading.
  • Not for sequential content. Steps in a process are a .stepper; a carousel implies its items are peers in no particular order.