UI.MD

Carousel

A carousel with motion and swipe, built on Embla. Supports horizontal and vertical orientation, keyboard navigation, and autoplay plugins.

Installation

$
npx shadcn@latest add https://ui.mattdowney.com/r/carousel.json

Usage

import {
  Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext,
} from "@/components/ui/carousel"

Examples

Default

One slide at a time, with arrows, swipe, and keyboard. opts={{ loop: true }} makes it cycle; leave it off and the arrows disable at each end instead.

Noun #301
Doodle #5064
CrypToadz #6500
CrypToadz #2391
Checks #122
DeadFellaz #9870
DeadFellaz #8181
DeadFellaz #2948
DeadFellaz #3848
DeadFellaz #7136

Multiple items

How many slides show at once is a basis-* class on CarouselItem, so it can change per breakpoint. The carousel itself needs no other change.

Noun #301
Doodle #5064
CrypToadz #6500
CrypToadz #2391
Checks #122
DeadFellaz #9870
DeadFellaz #8181
DeadFellaz #2948
DeadFellaz #3848
DeadFellaz #7136

Dots and counter

The position indicator. Dots are real buttons with accessible names and aria-current, not decoration, and the counter takes tabular-nums so climbing digits do not shift the row. Reads the index through setApi.

Noun #301
Doodle #5064
CrypToadz #6500
CrypToadz #2391
Checks #122
DeadFellaz #9870
DeadFellaz #8181
DeadFellaz #2948
DeadFellaz #3848
DeadFellaz #7136
1 / 10

Thumbnails

A synced filmstrip. The strip is its own bare Embla instance rather than a second Carousel: it needs no arrows, no keyboard capture and no region role of its own. dragFree lets it coast instead of snapping, which is what a row of small targets wants.

Noun #301
Doodle #5064
CrypToadz #6500
CrypToadz #2391
Checks #122
DeadFellaz #9870
DeadFellaz #8181
DeadFellaz #2948
DeadFellaz #3848
DeadFellaz #7136

Coverflow scale

useCarouselTween (shipped with this component as carousel-tween.ts) scales and dims each slide by its live distance from centre, so it tracks a drag 1:1. edgeFade dissolves the peeking neighbours at the clip edge. Over long slides pair with opts.duration 18-25: the default 14 overshoots and double-bounces.

Noun #301
Doodle #5064
CrypToadz #6500
CrypToadz #2391
Checks #122
DeadFellaz #9870
DeadFellaz #8181
DeadFellaz #2948
DeadFellaz #3848
DeadFellaz #7136

Vertical

orientation="vertical" stacks the slides and moves the arrows above and below. Give CarouselContent a bounded height through viewportClassName, not className: className lands on the track inside the viewport, where a height leaves every slide stacked.

Noun #301
Doodle #5064
CrypToadz #6500
CrypToadz #2391
Checks #122
DeadFellaz #9870
DeadFellaz #8181
DeadFellaz #2948
DeadFellaz #3848
DeadFellaz #7136

Auto height

autoHeight sizes the viewport to the slide in view, so slides of different heights each get their own instead of all being stretched to the tallest. Horizontal only; a vertical carousel needs its height from CSS instead.

Checks #122
Doodle #5064
Noun #301

Not every slide is a picture

This one is text, so its height comes from how much of it there is. The viewport follows it rather than the other way round, which is the whole point of autoHeight: the carousel does not need to know what you are going to put in it.

Autoplay

Needs npm i embla-carousel-autoplay. The ring fills over the delay, so it answers how long until the next slide, not just whether you can stop it. It pauses on hover and focus, is reachable by keyboard and touch, and never starts under prefers-reduced-motion.

Noun #301
Doodle #5064
CrypToadz #6500
CrypToadz #2391
Checks #122
DeadFellaz #9870
DeadFellaz #8181
DeadFellaz #2948
DeadFellaz #3848
DeadFellaz #7136

Fade

Needs npm i embla-carousel-fade. Slides crossfade in place instead of moving, which suits a hero or a single-image showcase where travel would be noise. The gutter has to come off: fade stacks the slides, so the kit's 16px inset would offset every crossfade by 16px.

Noun #301
Doodle #5064
CrypToadz #6500
CrypToadz #2391
Checks #122
DeadFellaz #9870
DeadFellaz #8181
DeadFellaz #2948
DeadFellaz #3848
DeadFellaz #7136

API Reference

PropTypeDefaultDescription
optsCarouselOptions-Embla options object (loop, align, duration, ...). The kit defaults duration to 14, an Embla physics value (~10 to 60), not milliseconds.
pluginsCarouselPlugin-Embla plugins array, e.g. autoplay from embla-carousel-autoplay.
orientation"horizontal" | "vertical""horizontal"Scroll axis. Vertical stacks slides and moves the arrows above and below the viewport.
setApi(api: CarouselApi) => void-Receives the Embla API instance for programmatic control (scrollTo, selectedScrollSnap, event listeners).
viewportClassNameCarouselContentstring-Classes for the clipping viewport rather than the flex track inside it. This is where a vertical carousel's height goes: className lands on the track, and a height there does not bound the element that clips, so every slide renders stacked. aspect-square derives the bound from the carousel's own width.
variantCarouselPrevious / CarouselNext"bare" | "chip""bare"bare is the naked chevron riding the content at half opacity. chip is a filled circular button (bg-card, ring edge) at full opacity, for arrows sitting over artwork, where a half-opacity chevron goes illegible.
toneCarouselPrevious / CarouselNext"default" | "accent""default"default reads the theme's neutrals. accent reuses Button's accent recipe: a bare accent arrow is an accent-coloured chevron, a chip accent arrow is a solid accent fill with Button's exact rest and hover pair, so it matches an accent button beside it.
edgeFadeCarouselContentboolean | numberfalseDissolves the slides at the viewport's edges instead of clipping them on a hard line. For peeking layouts (a fractional basis-*, a basis-auto strip, the coverflow tween), where the neighbour slide is otherwise guillotined at the clip edge. A mask rather than an overlay, so it works over any page background, and it follows orientation. A number sets the fade's width in pixels (true = 64); keep it comfortably inside the neighbour's visible peek or the whole neighbour smears. Each edge fades only while content extends past it, so at rest on the first slide the leading edge stays a hard line. A loop carousel keeps both edges faded.
autoHeightbooleanfalseSizes the viewport to the slide currently in view, so slides of different heights each get their own rather than all being stretched to the tallest. **Horizontal only**, and ignored when vertical: Embla measures its scroll bounds once at init, and on the y-axis the viewport height is itself the scroll axis, so sizing it afterwards leaves Embla thinking nothing can scroll. Give a vertical carousel a bounded height in CSS instead (aspect-square on CarouselContent). Costs a ResizeObserver over the slides, so it is opt-in.