Carousel
A carousel with motion and swipe, built on Embla. Supports horizontal and vertical orientation, keyboard navigation, and autoplay plugins.
Installation
$
Usage
import {
Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext,
} from "@/components/ui/carousel"Examples
Default
Multiple items
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| opts | CarouselOptions | - | Embla options object (loop, align, duration, ...). The kit defaults duration to 14, an Embla physics value (~10 to 60), not milliseconds. |
| plugins | CarouselPlugin | - | 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). |
"use client"
/**
* Carousel - Embla-based, mirroring shadcn's API.
*
* MOTION EXCEPTION: Embla animates scrolling with a physics attraction
* simulation, not CSS transitions, so this component intentionally does NOT use
* the kit's `ease-in-out` / `duration-*` motion tokens for slide movement - it
* structurally can't. The only tuning knob is Embla's `opts.duration` (a
* physics value, roughly 20-60, NOT milliseconds, with no easing curve). This
* is the industry-standard engine (shadcn uses it too); the trade is
* best-in-class drag/touch/momentum over token-exact easing. The arrows are
* bare chevrons (muted → foreground on hover), and reduced-motion is respected
* via the global fallback in globals.css plus `motion-reduce:transition-none`.
*/
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/registry/lib/utils"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
// A media query is an external store, so it is read with the hook built for
// one rather than mirrored into state by an effect. The old shape set state on
// mount, which renders twice on every carousel and is what
// `react-hooks/set-state-in-effect` flags. The server snapshot is `false`,
// matching what the state used to initialize to, so nothing about the rendered
// output changes.
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
React.useCallback((onStoreChange: () => void) => {
const mq = window.matchMedia(REDUCED_MOTION_QUERY)
mq.addEventListener("change", onStoreChange)
return () => mq.removeEventListener("change", onStoreChange)
}, []),
() => window.matchMedia(REDUCED_MOTION_QUERY).matches,
() => false
)
}
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const prefersReducedMotion = usePrefersReducedMotion()
const [carouselRef, api] = useEmblaCarousel(
{
// Embla `duration` is a physics value (~10-60), not milliseconds. 14 is the
// kit default - a crisp ~180ms settle within the kit's motion budget (Content
// 200 / Max 300); consumers can override via `opts`. Embla animates with a JS
// physics sim, not CSS transitions, so the global reduced-motion CSS fallback
// can't reach it - honor it explicitly with an instant (duration 0) jump that
// wins over any consumer `opts.duration`.
duration: 14,
...opts,
...(prefersReducedMotion ? { duration: 0 } : {}),
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
// Embla IS an external store: it owns the scroll position and emits when that
// changes. Reading it with useSyncExternalStore means the arrows are correct
// on the very first render, where the old effect had to set state after mount
// and render again. Each flag is read separately because a snapshot must be a
// stable value; returning `{prev, next}` would allocate a new object on every
// read and loop forever.
const subscribe = React.useCallback(
(onStoreChange: () => void) => {
if (!api) return () => {}
api.on("reInit", onStoreChange)
api.on("select", onStoreChange)
return () => {
api.off("reInit", onStoreChange)
api.off("select", onStoreChange)
}
},
[api]
)
const canScrollPrev = React.useSyncExternalStore(
subscribe,
() => api?.canScrollPrev() ?? false,
() => false
)
const canScrollNext = React.useSyncExternalStore(
subscribe,
() => api?.canScrollNext() ?? false,
() => false
)
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
// Don't hijack arrow keys from a focused control that owns them (text
// input caret, slider, another arrow-navigable widget) inside a slide.
const target = event.target as HTMLElement
if (
target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) ||
target.closest('[role="slider"],[role="radiogroup"],[role="listbox"],[role="menu"]')
) {
return
}
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
return (
<CarouselContext.Provider
value={{
carouselRef,
api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
data-slot="carousel"
onKeyDown={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
aria-label="Carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
data-slot="carousel-content"
className="overflow-hidden"
// Embla positions slides purely via transform, so the viewport must never
// scroll natively. Focusing a control inside an off-screen slide makes the
// browser scroll this container to reveal it, which silently desyncs every
// slide from Embla's transform. Pin the native scroll offset to 0.
onScroll={(event) => {
event.currentTarget.scrollLeft = 0
event.currentTarget.scrollTop = 0
}}
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
const carouselArrowClass =
"tap-target absolute flex cursor-pointer items-center justify-center rounded-md p-1 text-foreground opacity-50 outline-none transition-opacity duration-150 ease-out hover:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring-focus disabled:pointer-events-none disabled:opacity-25 motion-reduce:transition-none [&_svg]:size-6 [&_svg]:shrink-0"
function CarouselPrevious({
className,
...props
}: React.ComponentProps<"button">) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<button
type="button"
data-slot="carousel-previous"
className={cn(
carouselArrowClass,
orientation === "horizontal"
? "top-1/2 left-2 -translate-y-1/2 sm:-left-12"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
aria-label="Previous slide"
{...props}
>
<ChevronLeft />
</button>
)
}
function CarouselNext({
className,
...props
}: React.ComponentProps<"button">) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<button
type="button"
data-slot="carousel-next"
className={cn(
carouselArrowClass,
orientation === "horizontal"
? "top-1/2 right-2 -translate-y-1/2 sm:-right-12"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
aria-label="Next slide"
{...props}
>
<ChevronRight />
</button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}