Media
Carousel
An embla-powered slide carousel — autoplay with hover-pause, looped or bounded navigation, arrow buttons and synced pagination dots.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/carousel.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Carousel" component using
embla-carousel-react + embla-carousel-autoplay.
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: children (ReactNode — each direct child is one slide/frame),
autoplay (boolean | number, default false — false disables it, true
rotates every 4000ms, a number sets a custom interval in ms), loop
(boolean, default true), showDots (boolean, default true), showArrows
(boolean, default true), className.
Behavior
- useEmblaCarousel({ loop, align: "start" }, plugins) where plugins is
[Autoplay({ delay, stopOnMouseEnter: true, stopOnInteraction: false })]
when autoplay is truthy, else []. align: "start" is a no-op for a
100%-basis slide but keeps slides flush at the viewport edge instead of
embla's default centered peek once a customization lever narrows the
slide basis. stopOnInteraction: false matters even though nothing else
disables autoplay on interaction here — embla-carousel-autoplay only
wires its mouseleave "resume" listener when stopOnMouseEnter is combined
with stopOnInteraction: false; without it hovering once stops autoplay
for good. Pass fresh option/plugin literals every render —
embla-carousel-react diffs them by value and only reinitializes when
something actually changed.
- Track embla's live selectedIndex / canScrollPrev / canScrollNext /
scrollSnapList as an external store via useSyncExternalStore: getSnapshot
reads the embla API directly (safe — the carousel is already initialized
by the time emblaApi is non-null) and caches the result in a ref so it
only creates a new snapshot object when a value actually changed;
subscribe wires the "select" and "reInit" embla events straight to the
callback. This keeps the component free of any setState-in-effect —
nothing calls setState directly inside a useEffect body.
- Arrow buttons call emblaApi.scrollPrev() / scrollNext() and are disabled
from the same snapshot's canScrollPrev/canScrollNext (so at the ends of a
non-looping carousel they visibly disable, not just no-op).
- Dots render one per scrollSnapList() entry, onClick calls
emblaApi.scrollTo(index); the active dot comes from selectedIndex.
- Read prefers-reduced-motion via useSyncExternalStore on matchMedia
(server snapshot false). Autoplay never starts when reduced motion is
requested — manual arrows/dots/swipe still work fully, since those are
navigation, not decorative animation.
- Hide arrows/dots entirely when there's only one slide/snap.
Rendering & styling
- Semantic tokens only: bg-background/80 + backdrop-blur on the arrow
buttons, bg-primary for the active dot, bg-muted-foreground/30 for
inactive dots, focus-visible:ring-ring throughout. cn() merges className.
- Structure: outer relative div (role="region" aria-roledescription
="carousel") > overflow-hidden div (the embla viewport ref) > flex track
> one div per slide (min-w-0 flex-[0_0_100%], role="group"
aria-roledescription="slide", aria-label="N of M").
- Arrows are absolutely positioned circular buttons on both edges;
disabled state drops opacity and disables pointer events.
Customization levers
- Slide basis: change flex-[0_0_100%] to flex-[0_0_50%] (or
md:flex-[0_0_33%] etc.) to show multiple frames at once — arrows/dots
still advance one slide at a time since embla derives snap points from
each slide's actual rendered width.
- Autoplay pace: pass a number (e.g. autoplay={6000}) for a slower rotation,
or flip stopOnInteraction back to true (drop stopOnMouseEnter) if a manual
drag/click should stop autoplay for good instead of just pausing on hover.
- Gap between slides: add horizontal padding inside each slide's content
wrapper (e.g. px-1.5) rather than a track gap, so flex-basis math stays
exact.
- Fade instead of slide: swap embla's default scroll axis for embla's fade
plugin (embla-carousel-fade) to crossfade slides instead of translating.
- Thumbnail navigation: add a second, smaller Carousel (or plain thumbnail
row) synced to the same selectedIndex via a shared emblaApi.scrollTo call.Concepts
- Embla as an external store — instead of syncing embla's scroll state into React state inside an effect,
useSyncExternalStorereads the live API on render and only notifies React when the "select"/"reInit" events actually fire, so the component never needs a setState call sitting directly in an effect body. - Autoplay is ambient, not the navigation — arrows, dots and swipe are the real controls; autoplay just adds unattended motion on top, so it's the one thing that turns off under
prefers-reduced-motionwhile every manual path stays fully functional. - Hover-pause, not click-stop — the autoplay plugin pauses on
stopOnMouseEnterso a user resting their cursor over the carousel to read a slide doesn't get it swapped out from under them. - Snap points, not slide count — dots are rendered from
scrollSnapList()rather than the raw children count, so a customized multi-frame-visible layout (see the levers above) still gets one dot per actual stopping point. - Honest disabled ends — with
loop={false}, the arrow buttons visibly disable at the first/last slide instead of silently no-op-ing, so the boundary is discoverable without trial and error.