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…

"use client"

import * as React from "react"
import useEmblaCarousel from "embla-carousel-react"
import Autoplay from "embla-carousel-autoplay"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"

type EmblaApi = NonNullable<ReturnType<typeof useEmblaCarousel>[1]>

function subscribeReducedMotion(callback: () => void) {
  const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
  mq.addEventListener("change", callback)
  return () => mq.removeEventListener("change", callback)

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/carousel.json

Prompt

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, useSyncExternalStore reads 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-motion while every manual path stays fully functional.
  • Hover-pause, not click-stop — the autoplay plugin pauses on stopOnMouseEnter so 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.

On This Page