Carousel
Touch-friendly slider with slide, card, fade and free-scroll variants. Built on Embla with autoplay, a real rotation control, keyboard navigation, RTL and vertical support.
When not to use a carousel
Worth reading before the API, because it is the part most carousel documentation leaves out.
More first-party design systems refuse to ship a carousel than ship one. Base UI, MUI, Adobe Spectrum, Polaris, PatternFly, Atlassian and Primer have none. USWDS filed the request to prohibit them as "our first anti-feature request". GOV.UK removed its homepage carousel in 2012 after testing found users did not scroll past it. Carbon for IBM.com ships one but forbids autoplay and looping outright, citing WCAG 2.2.2.
The recurring finding is that content below the first slide is rarely seen. So:
- Do not put anything important on slide two. If every item matters, use a grid or a list — both are scannable at a glance and need no interaction.
- Prefer a carousel for browsing, not for deciding. Galleries, related products, editorial rails: good. A comparison someone must reason about: bad.
- Keep it short. Five frames or fewer is the usability recommendation. Past
about eight, dots stop being countable — use
CarouselCounter. - Give each carousel a real name describing what it holds, and do not stack two under one heading.
Examples
Default
A full-width slide carousel with looping, arrow nav and dot indicators.
Card
The card variant scales and fades adjacent slides for a centre-focused look.
Fade
Crossfades between slides — for testimonials and hero rotations, where a side-scroll would feel jarring.
Multi-peek
Fixed-width items leave the next slide cut off at the edge. That partial slide is the strongest discoverability cue a carousel has on a phone — stronger than dots.
Free-scroll rail
variant="scroll" is the momentum rail: no snapping, no arrows, no dots. The
shape used by story rows and category rails.
Autoplay and the rotation control
Counter
Past about eight slides, dots stop working. A fraction scales to any length.
Dot variants and the ceiling
Clickable dots carry real target floors — 24px pitch on desktop, 44px on
phones — so the row spreads with them. interactive={false} drops the targets
and the floors with them: the tight indicator pitch holds on every width, and
swipe, arrows and keyboard navigate. That is the form the showcase examples on
this page use.
Nav placement
All three layouts below use the same position="inline" — flanking the
viewport, below it beside the dots, or parked in a section header. Only the
surrounding markup differs, so there is no prop for this.
On a coloured surface
Vertical
RTL
Accessibility
Loading
Installation
npx shadcn@latest add https://tessinaui.com/r/carousel.jsonUsage
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
CarouselDots,
} from "@tessinaui/ui/carousel";
<Carousel size="full" loop aria-label="Featured products">
<CarouselContent>
<CarouselItem>Slide 1</CarouselItem>
<CarouselItem>Slide 2</CarouselItem>
<CarouselItem>Slide 3</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselDots />
</Carousel>aria-label is required in practice: a role="region" with no name is not
exposed as a landmark at all. Describe what the carousel contains, and do not
include the word "carousel" — aria-roledescription already says that, and
repeating it makes screen readers announce "Featured products, carousel,
carousel". The component dev-warns when the name is missing.
API
Carousel
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "card" | "fade" | "scroll" | "default" | card scales adjacent slides, fade crossfades, scroll is a free-momentum rail |
size | "sm" | "md" | "lg" | "full" | "auto" | "full" | Default item width — sm=200px, md=300px, lg=420px, full=viewport, auto=content |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "none" | Corner scale — 0/6/8/12/16/24px on panels; full caps at 24px per the panel rule |
tone | "default" | "on-color" | "default" | Switches arrows and dots to currentColor for a coloured or photographic surface |
orientation | "horizontal" | "vertical" | "horizontal" | Scroll axis. Vertical requires a height on the carousel |
gap | "none" | "sm" | "md" | "lg" | "xl" | "md" | Spacing between items (0/8/16/24/32px), applied as logical properties |
align | "start" | "center" | "end" | "start" | Where snap points align within the viewport |
loop | boolean | false | Infinite loop |
dragFree | boolean | variant === "scroll" | Momentum scrolling with no snapping |
autoplay | boolean | number | false | true = 4000ms, or a number of ms. Pair it with CarouselPlayPause |
keyboard | boolean | true | Arrow / Home / End on the viewport, and what makes the viewport focusable |
wheel | boolean | true | Trackpad and wheel scrolling along the carousel's axis; the scroll rail also remaps the cross axis for mouse wheels |
dir | "ltr" | "rtl" | inherited | Stamped only when set; otherwise the document's direction is inherited |
roleDescription | string | "carousel" | Localisable — no screen reader translates aria-roledescription |
slideRoleDescription | string | "slide" | Localisable, as above |
slideLabel | (index, count) => string | `${i+1} of ${n}` | Names each slide |
slidesLabel | string | "Slides" | Accessible name for the focusable viewport |
opts | EmblaOptionsType | — | Pass-through to Embla |
setApi | (api: CarouselApi) => void | — | Receive the Embla instance for imperative control |
CarouselItem
| Prop | Type | Default | Description |
|---|---|---|---|
size | CarouselSize | inherited | Override the carousel's size for this item |
index | number | render order | Position override. Only needed when slides are not rendered in source order |
CarouselPrevious / CarouselNext
| Prop | Type | Default | Description |
|---|---|---|---|
navVariant | "default" | "plain" | "ghost" | "solid" | "default" | Quiet to loud: plain has no fill until hover, ghost is translucent, default is filled and bordered, solid is the primary fill. Overlay plain/ghost lock to white ink on a scrim in both themes — they sit on the slides, and theme ink is invisible over a photo in light mode |
navSize | "sm" | "md" | "lg" | "md" | 32/40/48px on desktop; 44×44 below md for WCAG 2.5.5 |
position | "overlay" | "inline" | "overlay" | overlay floats over the viewport; inline flows in normal layout |
CarouselDots
| Prop | Type | Default | Description |
|---|---|---|---|
dotVariant | "circle" | "pill" | "line" | "circle" | Indicator shape |
maxDots | number | 8 | Past this, the row scrolls and distant dots scale down instead of the row growing |
label | string | "Choose slide to display" | Accessible name for the dot group |
interactive | boolean | true | false renders a decorative indicator (iOS/IKEA shape): tight pitch, no buttons, aria-hidden. The default's 24px pitch is the WCAG 2.5.8 floor for clickable dots — this is the one compliant way below it |
CarouselCounter
| Prop | Type | Default | Description |
|---|---|---|---|
format | (current, count) => string | `${c} / ${n}` | Text format |
Rendered aria-hidden: the carousel's live region already announces the same
position on change, and announcing it twice is worse than once.
CarouselPlayPause
| Prop | Type | Default | Description |
|---|---|---|---|
playLabel | string | "Start slide rotation" | Label while paused |
stopLabel | string | "Stop slide rotation" | Label while rotating |
navVariant / navSize | as above | navSize="sm" | Shares the nav button styling |
Renders null when the carousel has no autoplay, so it is safe to leave in a
composition whose autoplay is conditional. Render it before CarouselContent
so it is the first tab stop inside the carousel, which is what APG asks for.
CarouselProgress
A thin rail that fills over the autoplay interval, driven by the plugin's own timer. With rotation off it shows position in the set instead. Decorative.
CarouselScrollbar
A scroll-position line. The thumb's width is the fraction of the rail you can see and its position is how far along you are — a map of the track rather than a clock, so it stays meaningful with no autoplay and with more slides than you could ever dot. This is the affordance a long free-scrolling row wants; IKEA, Skillshare and Shop all pair one with theirs. Decorative.
<Carousel variant="scroll" size="auto" aria-label="Categories">
<CarouselContent>…</CarouselContent>
<CarouselScrollbar className="mt-4" />
</Carousel>CarouselSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
count | number | 3 | Placeholder slides |
size / rounded / gap / orientation | as the carousel | — | Reads the same maps the real component does |
aspectRatio | string | "16/9" | A carousel has no intrinsic height, so the placeholder must declare one |
showDots | boolean | false | Dots row under the viewport |
dotsInteractive | boolean | true | Mirrors CarouselDots' interactive — button-cell pitch vs the tight decorative indicator |
label | string | — | Silent unless set |
useCarouselApi
const { setApi, current, count } = useCarouselApi();
<Carousel setApi={setApi} aria-label="Gallery" />
<p>{current + 1} / {count}</p>Trackpad and wheel
Wheel support is on for every variant: a two-finger trackpad swipe along the carousel's axis moves it. Embla is transform-driven and has no native scroller to receive that gesture, so the official wheel plugin bridges it.
The variants differ only in how much of the wheel they take. Snapping carousels
listen on their own axis alone — a vertical scroll over a horizontal
carousel still scrolls the page, which is what keeps this from being the
most complained-about carousel behaviour on the web. variant="scroll" also
remaps the cross axis onto the rail, so a plain mouse wheel — which has no X
axis — can move it. wheel={false} opts any carousel out.
Autoplay behaviour
The contract follows the ARIA APG, which is stricter than the engine's defaults:
- Hover pauses and resumes. Moving the pointer away restarts rotation.
- Keyboard focus stops it for good. Tabbing in is a signal that someone is reading; rotation does not resume unless they ask for it.
- A pointer press stops it for good, same reasoning.
- Reduced motion starts it paused. Under
prefers-reduced-motion: reducenothing rotates until the play button is pressed. - The live region goes silent while rotating, so an auto-advancing carousel never narrates itself over the rest of the page.
Autoplay alone does not satisfy WCAG 2.2.2 — a visible, keyboard-operable
control does. Render CarouselPlayPause whenever you set autoplay.
Accessibility
- The root is a
regionwitharia-roledescription="carousel"and a name you supply. Missing names are dev-warned. - Each slide is a
groupwitharia-roledescription="slide"and a name —"3 of 10"by default, per APG's fallback for slides with no unique name. - Slides that are not in view are
inert, so hidden content stays out of both the tab order and the accessibility tree. A partially visible peeking slide stays interactive; only fully off-screen ones are taken out. - A polite live region announces the position after it settles, and is silent while autoplay runs.
- Dots are buttons in a named group, marked with
aria-current. They are not a tablist: that pattern demands roving focus andaria-selected, and half of it is worse than none. - Arrows are named and disable at the bounds.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves through the rotation control, the viewport, and the nav buttons |
| ← / → | Previous / next slide (horizontal) |
| ↑ / ↓ | Previous / next slide (vertical) |
| Home / End | First / last slide |
The viewport itself is focusable, which is what gives the arrow keys somewhere to land — a carousel whose slides hold no focusable content would otherwise be unreachable by keyboard entirely.
This is ours, not the engine's. Embla ships no keyboard navigation; earlier
versions of this page claimed it did. Set keyboard={false} to drop both the
bindings and the tab stop.
Assistive technology
| Reader | Status |
|---|---|
| VoiceOver (macOS) | Verified for roles, names and slide position |
| NVDA | Untested — cannot be run on the maintainers' machines |
| JAWS | Untested, as above |
aria-roledescription is not universally supported: NVDA announces it, JAWS
announces it twice, VoiceOver is inconsistent and TalkBack ignores it. Treat it
as an enhancement, never as the thing that tells a user this is a carousel.
Migration
| Change | Before | After |
|---|---|---|
| Accessible name | defaulted to "Carousel" | no default; pass aria-label, dev-warns without one |
rounded scale | 0/4/6/8/9999px | 0/6/8/12/16/24px, xl added, full capped at 24px |
| Fade slides | required a hand-passed index | derived from render order |
| Autoplay after hover | stopped permanently, unrecoverable | pauses and resumes; only focus or a press stops it for good |
variant="scroll" | native overflow scroll plus Embla | Embla only; implies dragFree |
| Skeleton announcement | always announced | silent unless label is passed |
The rounded change is visual and affects existing carousels. Everything else
either fixes a defect or adds API.
Notes for agents
- Every part carries a stable
data-slot:carousel,carousel-viewport,carousel-track,carousel-item,carousel-previous,carousel-next,carousel-dots,carousel-dot,carousel-counter,carousel-play-pause,carousel-progress,carousel-status,carousel-skeleton. - The active slide carries
data-active; the root carriesdata-variant,data-orientationanddata-tone. - Do not assert on slide visibility via CSS. Read
inertordata-active. - For unbounded or generated content, keep
loop={false}, useCarouselCounterrather than dots, and offer a "see all" link — an infinite carousel has no honest indicator and no end.