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
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.








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.








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.








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.








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.








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.








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.

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.








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.








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). |
| viewportClassNameCarouselContent | string | - | 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. |
| edgeFadeCarouselContent | boolean | number | false | Dissolves 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. |
| autoHeight | boolean | false | Sizes 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. |
"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 { cva, type VariantProps } from "class-variance-authority"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { IconChevronLeft as ChevronLeft, IconChevronRight as ChevronRight } from "@tabler/icons-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
/** Size the viewport to the slide currently in view, so slides of different
* heights each get their own instead of every one being stretched to the
* tallest.
*
* HORIZONTAL ONLY, and ignored when vertical. Embla measures its scroll
* bounds once at init; on the x-axis the viewport's height is not the scroll
* axis so changing it afterwards is free, but on the y-axis it IS, and a
* viewport sized after init leaves Embla thinking there is nothing to
* scroll. Give a vertical carousel a bounded height in CSS instead -
* `aspect-square` on `CarouselContent` derives one from the width without
* hardcoding pixels.
*
* Costs a `ResizeObserver` over the slides, so it is opt-in. */
autoHeight?: boolean
}
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
)
}
// useLayoutEffect warns during SSR, where there is no layout to read.
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
// @use-when a horizontally paged run of items.
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
autoHeight = false,
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`.
//
// 14 is tuned for SHORT hops (a strip of thumbnails, a few hundred px).
// Over a long slide distance (~500px+, e.g. one full-width card per
// snap) the same physics overshoots the snap by several px and creeps
// back over ~500ms, which reads as a double bounce. Measured headless:
// duration 18 lands a ~500px slide with 0.7px of overshoot
// (imperceptible) and is visually still by ~430ms; 25 kills the
// overshoot entirely but drags a ~1s settle tail. Long-slide carousels
// should pass `opts.duration` in the 18-25 range.
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"),
autoHeight,
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>
)
}
// The edge dissolve for `edgeFade`. The stops trace a SMOOTHSTEP curve, for
// the same reason the coverflow tween runs one: a two-stop ramp (or a ramp
// with one interpolation hint) has a slope corner where it meets full
// opacity, and over a narrow peek that corner reads as a visible seam, an
// abrupt line where the fade stops. Sampling smoothstep at quarter points
// (0.16 / 0.5 / 0.84) rounds both ends off, so the neighbour melts toward
// the clip edge with no start line and no stop line. Same skim idiom as
// ScrollFade, which owns a native scroller; Embla's viewport must never
// scroll natively, so here the mask goes straight on the clipping viewport.
// Each edge's width is a 0..1 factor (`--cf-prev` / `--cf-next`) driven from
// scroll position, so an edge only fades when content actually extends past
// it: at rest on the first slide the leading edge is a hard line, and the
// fade ramps in over the first slide's travel.
//
// The curve is an ease-out cube, `1 - (1 - t)^3`, and its shape is the whole
// fix for two failures that look opposite but share a cause. The fade's
// INNER TERMINUS is what the eye finds: any curve still carrying slope when
// it reaches full opacity draws a visible line where the fade stops, and
// narrowing the band alone only moves that line, it never removes it. The
// cube is steepest at the clip edge, where it dies into the background, and
// lands on full opacity with ZERO slope, so the fade has no findable end.
// Same principle as the tween's smoothstep at the snap.
//
// The band is FIXED PIXELS, not a viewport percentage, and that is the
// second lesson this mask paid for. A percentage band scales with the
// carousel while the visible neighbour sliver is set by basis, gutter and
// tween scale, so the same percentage was a kiss on one layout and swallowed
// the entire neighbour on another (measured: an 8% band was 31px over a 40px
// visible sliver). The width is the taste dial, so `edgeFade` accepts a
// number of pixels; 64 is the default, a long even dissolve over a generous
// peek. Tune it to the layout: it should stay comfortably inside the
// neighbour's visible sliver or the whole neighbour reads as a smear.
const EDGE_FADE_BAND_DEFAULT = 64
function edgeFadeMask(to: "right" | "bottom", b: number) {
const band = (v: string, at: number) => `calc(var(${v}, 0) * ${at}px)`
const inv = (v: string, at: number) => `calc(100% - var(${v}, 0) * ${at}px)`
// The stops trace an S-curve with zero slope at BOTH ends, so the fade
// neither snaps off at the clip edge nor leaves a findable line where it
// lands on full opacity. (An ease-out cube was tried first: its steep
// start at the clip edge read as the image being chopped.) Slightly
// steeper than plain smoothstep through the middle: pure smoothstep over
// the full band read as a blur across the artwork rather than a dissolve
// at its edge.
const stops = [
[0.125, 0.03],
[0.25, 0.12],
[0.375, 0.28],
[0.5, 0.5],
[0.625, 0.72],
[0.75, 0.88],
[0.875, 0.97],
] as const
const prev = stops
.map(([t, a]) => `rgb(0 0 0 / ${a}) ${band("--cf-prev", b * t)}`)
.join(", ")
const next = stops
.map(([t, a]) => `rgb(0 0 0 / ${a}) ${inv("--cf-next", b * t)}`)
.reverse()
.join(", ")
return `linear-gradient(to ${to}, rgb(0 0 0 / 0), ${prev}, #000 ${band("--cf-prev", b)}, #000 ${inv("--cf-next", b)}, ${next}, rgb(0 0 0 / 0))`
}
function CarouselContent({
className,
viewportClassName,
edgeFade = false,
...props
}: React.ComponentProps<"div"> & {
/** Classes for the clipping VIEWPORT, not the track inside it.
*
* This is where a vertical carousel's height goes. `className` lands on the
* flex track, and a height there does not bound the element that actually
* clips, so every slide renders stacked. `aspect-square` here derives the
* bound from the carousel's own width with no pixel to guess. Same split,
* and the same reason, as ScrollFade's `viewportClassName`. */
viewportClassName?: string
/** Dissolve the slides at the viewport's edges instead of clipping them on a
* hard line. For peeking layouts (a fractional `basis-*`, `basis-auto`
* strips, the coverflow tween), where the neighbour slide is otherwise
* guillotined at the clip edge. A mask, not an overlay, so it works over
* any page background. Follows `orientation`, and each edge fades only
* while content extends past it: at rest on the first slide the leading
* edge stays a hard line. A number sets the fade's width in pixels
* (default 64); keep it comfortably inside the neighbour's visible peek,
* or the whole neighbour reads as a smear instead of an image. */
edgeFade?: boolean | number
}) {
const { carouselRef, orientation, api, autoHeight, opts } = useCarousel()
const viewportRef = React.useRef<HTMLDivElement | null>(null)
// Embla hands back a callback ref, so we fan the node out to both: Embla
// needs it to drive the carousel, we need it to size the viewport.
const setViewport = React.useCallback(
(node: HTMLDivElement | null) => {
viewportRef.current = node
carouselRef(node)
},
[carouselRef]
)
// Layout effect, not effect: this writes the height, and running it after
// paint is a visible jump on every mount.
useIsomorphicLayoutEffect(() => {
const viewport = viewportRef.current
// HORIZONTAL only, and that is structural rather than a missing feature.
// Embla measures its scroll bounds once at init. On the x-axis the
// viewport's HEIGHT is not the scroll axis, so changing it afterwards is
// free. On the y-axis it IS the scroll axis: a viewport with no height at
// init equals the full stacked content, so Embla concludes there is
// nothing to scroll and leaves both arrows `disabled` forever. A vertical
// carousel therefore needs its height from CSS, before Embla measures -
// `aspect-square` on CarouselContent, or any other bounded height.
if (orientation === "vertical") return
if (!autoHeight || !api || !viewport) return
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches
let first = true
const apply = () => {
const node = api.slideNodes()[api.selectedScrollSnap()]
if (!node) return
// Measure the SLIDE, which we never resize - reading the viewport we DO
// resize would feed our own value straight back in.
const next = node.offsetHeight
if (viewport.style.height === `${next}px`) return
// Unlike the Card shell, this height is STRUCTURAL: a vertical carousel
// has no height at all without it. Reduced motion therefore still sets
// the height and only drops the transition. Same on the first measure,
// which is a baseline, not a change.
viewport.style.transition = reduce || first ? "" : "height 200ms ease-out"
viewport.style.height = `${next}px`
first = false
}
// Slides change size after mount constantly: an image with no intrinsic
// size finishing its load, a font swap, text reflowing at a new width.
// Without this the viewport keeps whatever it measured before that landed.
const observer = new ResizeObserver(apply)
const observe = () => {
observer.disconnect()
api.slideNodes().forEach((node) => observer.observe(node))
apply()
}
observe()
api.on("select", apply)
api.on("reInit", observe)
return () => {
api.off("select", apply)
api.off("reInit", observe)
observer.disconnect()
}
}, [api, autoHeight, orientation])
// Position-driven like the coverflow tween, so the fade tracks a drag 1:1
// and needs no reduced-motion branch: each edge's factor ramps 0..1 over one
// snap step of travel away from that end. A loop carousel always has content
// past both edges, so both factors pin to 1.
const loop = opts?.loop === true
React.useEffect(() => {
const viewport = viewportRef.current
if (!edgeFade || !api || !viewport) return
const apply = () => {
let prev = 1
let next = 1
if (!loop) {
const snaps = api.scrollSnapList()
if (snaps.length <= 1) {
// One snap means no overflow (containScroll folds a fully visible
// strip down to one), so neither edge has content past it.
prev = 0
next = 0
} else {
const step = (snaps[snaps.length - 1] - snaps[0]) / (snaps.length - 1)
const progress = api.scrollProgress()
prev = Math.min(Math.max(progress / step, 0), 1)
next = Math.min(Math.max((1 - progress) / step, 0), 1)
}
}
viewport.style.setProperty("--cf-prev", prev.toFixed(3))
viewport.style.setProperty("--cf-next", next.toFixed(3))
}
apply()
api.on("scroll", apply)
api.on("reInit", apply)
return () => {
api.off("scroll", apply)
api.off("reInit", apply)
viewport.style.removeProperty("--cf-prev")
viewport.style.removeProperty("--cf-next")
}
}, [api, edgeFade, loop])
return (
<div
ref={setViewport}
data-slot="carousel-content"
className={cn("overflow-hidden", viewportClassName)}
style={
edgeFade
? (() => {
const mask = edgeFadeMask(
orientation === "vertical" ? "bottom" : "right",
typeof edgeFade === "number" ? edgeFade : EDGE_FADE_BAND_DEFAULT
)
return { maskImage: mask, WebkitMaskImage: mask }
})()
: undefined
}
// 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",
// A vertical slide's basis-full is a PERCENTAGE of the track's
// height. Against an indefinite height a percentage falls back to
// auto, so the slides get no height, Embla measures nothing to
// scroll and both arrows stay `disabled`. h-full makes the track
// definite by inheriting the bounded viewport. Comes before
// `className`, so a caller putting the height on the track instead
// (`className="h-64"`, the shadcn shape) still wins.
orientation === "vertical" && "h-full",
// A flex row stretches every slide to the tallest one, which is the
// opposite of measuring each slide's own height.
autoHeight && orientation === "horizontal" && "items-start",
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",
// The vertical twin of min-w-0, and it was missing. A flex item's
// `min-height: auto` lets it grow past its basis to fit its content,
// so a slide whose image resolved to the full viewport height came out
// one gutter TALLER than the viewport. Embla then centres the
// oversized slide and clips half the overflow off the top.
orientation === "vertical" && "min-h-0",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
const carouselArrowVariants = cva(
"tap-target absolute flex cursor-pointer items-center justify-center outline-none focus-visible:ring-[3px] focus-visible:ring-ring-focus disabled:pointer-events-none motion-reduce:transition-none [&_svg]:shrink-0",
{
variants: {
variant: {
// Bare chevron riding the content. Legibility comes from the opacity
// ramp, which is also the hover affordance.
bare: "rounded-md p-1 opacity-50 transition-opacity duration-150 ease-out hover:opacity-100 disabled:opacity-25 [&_svg]:size-6",
// Floating chip: a filled control for arrows sitting over artwork,
// where a half-opacity bare chevron goes illegible. Full opacity at
// rest; the ring is the edge, per the card tier.
chip: "size-8 rounded-full shadow-ring-md transition-[transform,color,background-color] motion-reduce:transition-[color,background-color] duration-150 ease-out active:scale-[0.98] disabled:opacity-50 [&_svg]:size-5",
},
// "default" reads the theme's neutrals; "accent" reuses Button's accent
// recipe, so an accent arrow and an accent button match at rest and on
// hover.
tone: {
default: "",
accent: "",
},
},
compoundVariants: [
{ variant: "bare", tone: "default", className: "text-foreground" },
{ variant: "bare", tone: "accent", className: "text-accent dark:text-accent-400" },
{ variant: "chip", tone: "default", className: "bg-card text-muted-foreground hover:text-foreground" },
{ variant: "chip", tone: "accent", className: "bg-accent dark:bg-accent-600 text-accent-foreground hover:bg-accent-600 dark:hover:bg-accent-500" },
],
defaultVariants: { variant: "bare", tone: "default" },
}
)
type CarouselArrowProps = React.ComponentProps<"button"> &
VariantProps<typeof carouselArrowVariants>
function CarouselPrevious({
className,
variant,
tone,
...props
}: CarouselArrowProps) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<button
type="button"
data-slot="carousel-previous"
className={cn(
carouselArrowVariants({ variant, tone }),
orientation === "horizontal"
? "top-1/2 left-2 -translate-y-1/2 sm:-left-12"
// Inside first, outside at sm+, mirroring the horizontal arrows. The
// outside position needs 48px of clearance the container may not
// have, and an arrow clipped by an overflow-hidden ancestor is a
// control that silently cannot be clicked.
: "top-2 left-1/2 -translate-x-1/2 rotate-90 sm:-top-12",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
aria-label="Previous slide"
{...props}
>
<ChevronLeft />
</button>
)
}
function CarouselNext({
className,
variant,
tone,
...props
}: CarouselArrowProps) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<button
type="button"
data-slot="carousel-next"
className={cn(
carouselArrowVariants({ variant, tone }),
orientation === "horizontal"
? "top-1/2 right-2 -translate-y-1/2 sm:-right-12"
: "bottom-2 left-1/2 -translate-x-1/2 rotate-90 sm:-bottom-12",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
aria-label="Next slide"
{...props}
>
<ChevronRight />
</button>
)
}
// The coverflow scale/fade treatment lives in carousel-tween.ts (its own
// directive-free module: a hook exported from this "use client" file would
// reach a Server Component as a client reference, not a value).
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}import * as React from "react"
import type { CarouselApi } from "@/registry/ui/carousel"
/* ─────────────────────────────────────────────────────────
* COVERFLOW TWEEN
*
* Opt-in scale/fade for peek carousels: every slide's size
* and opacity follow its live distance from the centre snap,
* so the treatment tracks a drag 1:1. It is driven by scroll
* POSITION, not a timed animation, which is why it needs no
* reduced-motion branch of its own: the Carousel's instant
* reduced-motion jump lands the end state in one frame.
*
* The curve is smoothstep, and both of its properties are
* load bearing. A piecewise-linear ramp (clamp min/max on a
* linear strength) puts a velocity corner at each end, and
* the arriving card visibly POPS as it crosses them.
* Smoothstep's zero slope at d=0 also absorbs Embla's settle
* jitter for free: a hair of position wobble around the snap
* moves the scale by effectively nothing, so no dead band is
* needed (a dead band was the first, worse fix for the same
* symptom).
*
* Distance is measured in slide POSITIONS (normalised by the
* average snap spacing), so the curve spans exactly
* centre-to-neighbour however many slides there are.
*
* Pairs with: `basis-auto` items sized by their content and
* `opts={{ align: "center" }}` for the peek layout, and a
* long-slide `duration` (see the note on the Carousel's
* default).
*
* const [api, setApi] = React.useState<CarouselApi>()
* useCarouselTween(api)
* <Carousel setApi={setApi} opts={{ align: "center", duration: 18 }}>
*
* SERVER RENDERING: the hook is runtime JS, so the first
* thing a server-rendered page paints is the raw HTML —
* every slide at full size — and the neighbours visibly
* snap down when the tween takes over after hydration.
* Two remedies, by start slide:
*
* - Start slide is known at render time: pre-pose each
* slide with `carouselTweenRestStyle` (below), so the
* HTML already holds the pose the tween will compute and
* the handoff is invisible. Content paints immediately.
* - Start slide is dynamic or mid-list: pre-posing cannot
* fix the track itself, which paints left-aligned at
* slide 0 until Embla positions it. Fade the whole
* carousel in on the api's arrival instead (opacity
* only, so reduced motion needs no branch) — the
* "Coverflow scale" docs example does exactly this.
*
* Its own directive-free module, not an export of
* carousel.tsx: a hook leaving a "use client" module reaches
* a Server Component as a client reference, not a value.
* ───────────────────────────────────────────────────────── */
function smoothstep(t: number) {
return t * t * (3 - 2 * t)
}
const SCALE_MIN_DEFAULT = 0.9
const OPACITY_MIN_DEFAULT = 0.85
/** The pose `useCarouselTween` will compute for a slide `distance` positions
* from centre (1 = any neighbour or further), as an inline style for
* server-rendered markup. Render it on each slide so the pre-hydration HTML
* already matches the tween and nothing snaps when the hook takes the nodes
* over — same properties, so the handoff is an overwrite, not a fight.
* Returns undefined at distance 0 (the centred slide needs no style). Pass
* the same options given to the hook, or the poses desync from the tween. */
export function carouselTweenRestStyle(
distance: number,
{
scaleMin = SCALE_MIN_DEFAULT,
opacityMin = OPACITY_MIN_DEFAULT,
}: { scaleMin?: number; opacityMin?: number } = {}
): React.CSSProperties | undefined {
const eased = smoothstep(Math.min(Math.abs(distance), 1))
if (eased === 0) return undefined
return {
transform: `scale(${(1 - eased * (1 - scaleMin)).toFixed(4)})`,
opacity: Number((1 - eased * (1 - opacityMin)).toFixed(3)),
}
}
// Layout effect, not effect: the first apply() must land before the browser
// paints the render in which the Embla api arrives, or every neighbour paints
// one frame at full size and visibly snaps down. useLayoutEffect warns during
// SSR, where there is no layout to read.
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect
export function useCarouselTween(
api: CarouselApi,
{
scaleMin = SCALE_MIN_DEFAULT,
opacityMin = OPACITY_MIN_DEFAULT,
}: {
/** Scale of a slide one full position from centre (default 0.9). */
scaleMin?: number
/** Opacity of a slide one full position from centre (default 0.85).
* Keep it high when an edge mask also fades the strip: two dimmers
* stack, and neighbours read washed out well before either looks
* strong on its own. */
opacityMin?: number
} = {}
) {
useIsomorphicLayoutEffect(() => {
if (!api) return
const apply = () => {
const snaps = api.scrollSnapList()
const progress = api.scrollProgress()
const step =
snaps.length > 1
? (snaps[snaps.length - 1] - snaps[0]) / (snaps.length - 1)
: 1
api.slideNodes().forEach((node, index) => {
const d = Math.min(Math.abs(snaps[index] - progress) / step, 1)
const eased = smoothstep(d)
node.style.transform = `scale(${(1 - eased * (1 - scaleMin)).toFixed(4)})`
node.style.opacity = (1 - eased * (1 - opacityMin)).toFixed(3)
})
}
apply()
api.on("scroll", apply)
api.on("reInit", apply)
return () => {
api.off("scroll", apply)
api.off("reInit", apply)
// Hand the slides back clean, so toggling the hook off (or unmounting
// into a plain carousel) does not strand a shrunken, faded slide.
api.slideNodes().forEach((node) => {
node.style.transform = ""
node.style.opacity = ""
})
}
}, [api, scaleMin, opacityMin])
}