Add this component

npx zentauri-ui add carousel

Accessibility notes

Keyboard accessible by default with semantic markup, visible focus treatment, and tokenized states. Add descriptive labels for icon-only or decorative usage.

Dependency notes

Check Installation for shared peers. This component keeps styling in Tailwind classes and the --zui-* token contract.

Data display

Carousel

The Carousel component moves a flex track across a clipped viewport. Slide width and track offset are derived from one shared piece of CSS arithmetic, so every rest position lands exactly on a slide boundary no matter how many slides are visible or how wide the gap is. Slides per view, orientation, looping, pointer dragging, auto-play, arrows, dots, a counter, and a progress bar are all props.

Carousel playground

Slide width and track offset come from one piece of arithmetic, so every rest position lands exactly on a slide boundary whatever you set slidesPerView and the gap to. Toggle Show output / Show code and the snippet updates to match the selected variant — including the import, which switches to the animated entry as soon as you pick an animation or a reveal.

Focus the viewport and walk it with the arrow keys — Home and End jump to the first and last rest position. You can also drag the track with a pointer; releasing past a fifth of a slide advances, anything shorter snaps back.


All appearances

An appearance publishes one accent, read as a background by the active dot and the progress bar — which is why the gradient-* entries work exactly like the solid ones. Click any swatch to load it into the playground above.

default
secondary
destructive
blue
cyan
green
lime
mint
ocean
sapphire
lavender
ruby
red
slate
zinc
royal
electric
forest
sunset
magenta
crimson
emerald
indigo
purple
pink
rose
sky
teal
yellow
orange
gradient-blue
gradient-green
gradient-red
gradient-yellow
gradient-purple
gradient-teal
gradient-indigo
gradient-pink
gradient-orange

Multiple slides per view

slidesPerView divides the viewport, subtracting only the gaps that sit between visible slides. Six slides three at a time leaves four rest positions, not six, and the dots follow that count rather than the slide count.


Vertical track

orientation="vertical" switches the track to a column, moves the arrows to the top and bottom edges, stacks the dots, and rebinds the keyboard to ArrowUp / ArrowDown. Percentage slide sizes need a definite height to resolve against, so the viewport ships a themeable default via --zui-carousel-vertical-height.


Auto-play

Auto-play wraps at the end regardless of loop, because a timer that stops dead after one pass is not what anyone means by auto-play. It pauses while the pointer is over the carousel, while focus is inside it, and mid-drag — and it never starts at all under prefers-reduced-motion.


Compound API

Compose the parts when the shorthand's layout is not the one you want. Carousel.Content hands each child its position, so Carousel.Item never takes an index prop, and the controls can go anywhere inside the root.


Controlled index

Pass index with onIndexChange to drive the track from your own state — useful when the carousel is one view of a step sequence that other controls also move.


Showing step 1

CSS variable overrides

Override these carousel variables on :root, a theme selector, or a component wrapper. The appearance palettes publish a single --zui-carousel-<appearance>-accent used as a background by the active dot and the progress bar, so a gradient works anywhere a solid color does. Layout values the component computes from its props (--carousel-item-size, --carousel-offset, --carousel-progress, and the motion pair the animated track writes) are inline styles rather than theme tokens, so change those through the props.

121 variables

Pattern: --zui-<component>-<slot?>-<variant?>-<property>-<state?>-dark?

:root {
  --zui-carousel-blue-accent: #2563eb;
  --zui-carousel-control-bg: #ffffff;
  --zui-carousel-control-bg-hover: #f1f5f9;
  --zui-carousel-control-border: #e2e8f0;
  --zui-carousel-control-fg: #0f172a;
  --zui-carousel-control-inset: 0.75rem;
  --zui-carousel-control-radius: 9999px;
  --zui-carousel-control-shadow: 0 1px 2px #0f172a14;
  --zui-carousel-controls-gap: 0.75rem;
  --zui-carousel-counter-fg: #64748b;
  --zui-carousel-crimson-accent: #b91c1c;
  --zui-carousel-cyan-accent: #0891b2;
  --zui-carousel-default-accent: #0f172a;
  --zui-carousel-destructive-accent: #dc2626;
  --zui-carousel-dot-bg: #94a3b8;
  --zui-carousel-dots-gap: 0.375rem;
  --zui-carousel-duration: 320ms;
  --zui-carousel-easing: cubic-bezier(0.22,1,0.36,1);
  --zui-carousel-electric-accent: #0ea5e9;
  --zui-carousel-emerald-accent: #059669;
  --zui-carousel-fg: #0f172a;
  --zui-carousel-focus-ring: #475569;
  --zui-carousel-forest-accent: #166534;
  --zui-carousel-frame-bg: #ffffff;
  --zui-carousel-frame-border: #e2e8f0;
  --zui-carousel-frame-padding: 0.75rem;
  --zui-carousel-frame-shadow: 0 1px 2px #0f172a14;
  --zui-carousel-glass-bg: #ffffff14;
  --zui-carousel-glass-border: #ffffff33;
  --zui-carousel-gradient-blue-accent: linear-gradient(90deg,#2563eb,#7c3aed);
  --zui-carousel-gradient-green-accent: linear-gradient(90deg,#16a34a,#0d9488);
  --zui-carousel-gradient-indigo-accent: linear-gradient(90deg,#4f46e5,#7c3aed);
  --zui-carousel-gradient-orange-accent: linear-gradient(90deg,#ea580c,#ca8a04);
  --zui-carousel-gradient-pink-accent: linear-gradient(90deg,#db2777,#e11d48);
  --zui-carousel-gradient-purple-accent: linear-gradient(90deg,#7c3aed,#db2777);
  --zui-carousel-gradient-red-accent: linear-gradient(90deg,#dc2626,#db2777);
  --zui-carousel-gradient-teal-accent: linear-gradient(90deg,#0d9488,#0284c7);
  --zui-carousel-gradient-yellow-accent: linear-gradient(90deg,#ca8a04,#ea580c);
  --zui-carousel-green-accent: #16a34a;
  --zui-carousel-indigo-accent: #4f46e5;
  --zui-carousel-lavender-accent: #8b5cf6;
  --zui-carousel-lime-accent: #65a30d;
  --zui-carousel-magenta-accent: #c026d3;
  --zui-carousel-mint-accent: #10b981;
  --zui-carousel-ocean-accent: #0284c7;
  --zui-carousel-orange-accent: #ea580c;
  --zui-carousel-pink-accent: #db2777;
  --zui-carousel-progress-height: 0.25rem;
  --zui-carousel-progress-track-bg: #e2e8f0;
  --zui-carousel-purple-accent: #7c3aed;
  --zui-carousel-radius: 0.75rem;
  --zui-carousel-red-accent: #dc2626;
  --zui-carousel-ring-offset: #f8fafc;
  --zui-carousel-rose-accent: #e11d48;
  --zui-carousel-royal-accent: #4338ca;
  --zui-carousel-ruby-accent: #be123c;
  --zui-carousel-sapphire-accent: #1d4ed8;
  --zui-carousel-secondary-accent: #475569;
  --zui-carousel-sky-accent: #0284c7;
  --zui-carousel-slate-accent: #475569;
  --zui-carousel-stack-gap: 0.75rem;
  --zui-carousel-sunset-accent: #ea580c;
  --zui-carousel-teal-accent: #0d9488;
  --zui-carousel-vertical-height: 18rem;
  --zui-carousel-yellow-accent: #ca8a04;
  --zui-carousel-zinc-accent: #52525b;
}

/* Dark theme variables follow the same names with -dark appended. */
.dark {
  --zui-carousel-control-bg-dark: #0f172a;
  --zui-carousel-control-bg-hover-dark: #1e293b;
  /* ...same variables with -dark at the end */
}

How it works

A clipped viewport holds a flex track whose children are the slides. The root writes --carousel-item-size and --carousel-offset as inline styles: the first is the exact slide width for the current slidesPerView, the second multiplies that width plus one gap by the active index and adds any live drag distance. Percentages in both resolve against the track, whose main-axis size equals the viewport's, so the two expressions can never disagree about where a slide begins.

Slides per view

slidesPerView sets how many slides sit at one rest position. The maximum index becomes slideCount - slidesPerView, so a six-slide carousel three at a time has four rest positions rather than six, and the dots, the counter, and the progress bar all follow that number. Every slide inside the active window is marked data-active, so peeking layouts can style the visible group rather than a single slide.

Orientation

orientation="vertical" turns the track into a column, translates on the block axis, stacks the dots, moves inside arrows to the top and bottom edges, and rebinds the keyboard to ArrowUp and ArrowDown. A column's percentage slide sizes resolve against height, which needs a definite value, so the vertical viewport ships a default height through --zui-carousel-vertical-height that a token or a className can replace.

Dragging

Dragging uses pointer events on the viewport, so it works for mouse, touch, and pen without a motion library. dragThreshold is a fraction of one slide step and defaults to 0.2; travel beyond it commits, and a longer flick commits proportionally more steps. While a drag is in flight the track drops its CSS transition and carries data-dragging, and at a non-looping boundary the offset is damped so the end of the list is felt rather than crossed.

Auto-play

autoPlay advances on a timer set by autoPlayInterval, wrapping at the end so the rotation continues past the last slide. pauseOnHover and pauseOnFocus are on by default, a drag suspends the timer, and prefers-reduced-motion suppresses it entirely. Auto-play is a convenience, not a substitute for controls: the arrows, dots, and keyboard all stay live while it runs, which is what satisfies the pause requirement for moving content.

Compound API

Use the shorthand and pass slides as children, or compose Carousel.Root, Carousel.Viewport, Carousel.Content, Carousel.Item, Carousel.Controls, Carousel.Previous, Carousel.Next, Carousel.Dots, Carousel.Counter, and Carousel.Progress. The root owns index state, geometry, keyboard handling, and the drag gesture; Carousel.Content counts its children and hands each one its position, so items never take an index prop. Pass slideCount on the root when slides live outside a Carousel.Content, or to keep the dots correct on the first server render.

Animated entry

The animated entry keeps placement in CSS and springs only the slide index, writing --carousel-motion-index and --carousel-motion-drag for the browser to fold into the shared calc(). That keeps static and animated tracks pixel-identical at rest while letting a spring overshoot on the way. animation selects the physics preset and reveal fades, scales, or blurs slides outside the active window; both collapse to no motion under prefers-reduced-motion.

Keyboard interaction

Tab to the viewport, then use ArrowLeft and ArrowRight (ArrowUp and ArrowDown when vertical) to step, PageUp and PageDown to do the same, and Home and End to jump to the first and last rest position. The arrows and dots are ordinary buttons, so they are reachable in sequence, and focusing anything inside a clipped slide brings that slide into view first.

Common use cases

  • Image and media galleries
  • Product or pricing card rows
  • Onboarding and feature tours
  • Testimonial and logo rotators
  • Dashboard panels that page horizontally
  • Vertical changelog and release feeds

Accessibility

The root is a region with aria-roledescription="carousel" and a default accessible name that a caller-supplied aria-label or aria-labelledby replaces. Each slide is a group with aria-roledescription="slide" and an "n of total" label, and slides outside the active window are aria-hidden. The track announces user-driven moves politely and goes silent while auto-rotating. Arrows carry Previous slide and Next slide names and disable at the ends, dots expose aria-current, and the progress bar is a named progressbar with real min, max, and now values.

FAQ

How does the carousel show more than one slide at a time?

Set slidesPerView. Each slide takes calc((100% - (slidesPerView - 1) * gap) / slidesPerView) of the viewport, subtracting only the gaps that sit between visible slides, and the track offset multiplies that same width plus one gap. Because both expressions share the arithmetic there is no rounding drift, and the number of rest positions becomes slideCount - slidesPerView + 1, which is what the dots and the counter report.

Does the carousel work with a keyboard?

Yes. The viewport is focusable and handles the axis arrow keys, PageUp and PageDown, and Home and End. Arrows, dots, and the counter are real buttons with accessible names, the arrows disable themselves at the ends when loop is off, and tabbing into a slide that is currently clipped scrolls it into view so focus never lands somewhere invisible.

Is dragging available without framer-motion?

Yes. The static entry drags with plain pointer events and settles with a CSS transition, so pointer dragging costs no extra dependency. Releasing past dragThreshold (a fifth of a slide by default) advances, a longer flick advances by more than one, and anything shorter snaps back. Non-looping ends rubber-band instead of tracking the pointer into empty space.

What does the animated entry add?

Spring physics on the track. It writes two CSS custom properties that the browser folds into the same calc() the static entry uses, so a springing track and a resting one agree on where every slide starts. Pick a preset with animation (glide, smooth, snappy, bouncy) and optionally de-emphasise off-screen slides with reveal (fade, scale, blur). Import it from @zentauri-ui/zentauri-components/ui/carousel/animated; the static entry never pulls framer-motion in.

Does auto-play respect reduced motion?

Yes. Auto-play never starts when prefers-reduced-motion is set, and while running it pauses on hover, on focus within the carousel, and during a drag. It wraps at the end regardless of loop, since a timer that stops after one pass is not useful. The track is a polite live region for user-driven moves and drops to aria-live="off" while auto-rotating, so a screen reader is not interrupted by a timer.

Can I customize the appearance?

Yes. The component ships 39 appearance palettes — 30 solid colors plus nine gradients — and each publishes a single --zui-carousel-<appearance>-accent that the active dot and the progress bar read as a background, which is why gradients work exactly like solids. Every other surface (controls, dots, frames, counter, focus rings) is its own --zui-carousel-* variable with a light and a dark value, and the viewport takes none, bordered, card, or glass frames across sm/md/lg sizes.