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.
<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-markergives every slide its own dot and::scroll-buttongives 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.
<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.
| 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
| 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-markerdots 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::beforeis 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-currenton 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-labelon the.carousel, and consideraria-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
.gridand 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.