Native scroll-snap slides with momentum on touch, mouse dragging, arrow keys, dots, thumbnails and an autoplay that pauses when it should.
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel"
const slides = [1, 2, 3, 4, 5]
export function CarouselDemo() {
return (
<div className="w-full max-w-xs px-12">
<Carousel aria-label="Numbers">
<CarouselContent>
{slides.map((value) => (
<CarouselItem key={value}>
<div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
{value}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<div className="mt-3 flex justify-center">
<CarouselDots />
</div>
</Carousel>
</div>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/carousel.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/carousel.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { IconArrowDown, IconArrowLeft, IconArrowRight, IconArrowUp, IconPlayerPause, IconPlayerPlay, } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Button } from "@/components/ui/button" import { NumberFlow } from "@/components/ui/number-flow" type CarouselOrientation = "horizontal" | "vertical" type CarouselEvent = "select" | "scroll" | "settle" | "reInit" type CarouselListener = (api: CarouselApi, event: CarouselEvent) => void type CarouselAutoplay = boolean | { delay?: number } type CarouselApi = { scrollPrev: (jump?: boolean) => void scrollNext: (jump?: boolean) => void scrollTo: (index: number, jump?: boolean) => void scrollToSlide: (slideIndex: number, jump?: boolean) => void canScrollPrev: () => boolean canScrollNext: () => boolean selectedScrollSnap: () => number scrollSnapList: () => number[] slidesInView: () => number[] slideNodes: () => HTMLElement[] viewportNode: () => HTMLElement | null play: () => void stop: () => void isPlaying: () => boolean on: (event: CarouselEvent, listener: CarouselListener) => CarouselApi off: (event: CarouselEvent, listener: CarouselListener) => CarouselApi } type CarouselSnapshot = { selected: number snapCount: number slideCount: number firstInView: number lastInView: number canScrollPrev: boolean canScrollNext: boolean playing: boolean running: boolean cycle: number announcement: string } type CarouselOptions = { orientation: CarouselOrientation rewind: boolean mouseDrag: boolean delay: number controlledIndex: number | undefined defaultIndex: number onIndexChange: ((index: number) => void) | undefined } const defaultDelay = 5000 const dragThreshold = 5 const settleFallback = 150 const intentWindow = 800 const initialSnapshot: CarouselSnapshot = { selected: 0, snapCount: 0, slideCount: 0, firstInView: 0, lastInView: 0, canScrollPrev: false, canScrollNext: false, playing: false, running: false, cycle: 0, announcement: "", } function prefersReducedMotion() { return ( typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } function isEditable(target: EventTarget | null) { return ( target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select, [contenteditable='true']") !== null) ) } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } type DragState = { pointerId: number start: number startPosition: number lastPosition: number lastTime: number velocity: number active: boolean } class CarouselStore { options: CarouselOptions root: HTMLElement | null = null viewport: HTMLElement | null = null container: HTMLElement | null = null snaps: number[] = [] slides: HTMLElement[] = [] slideSnaps: number[] = [] slideRanges: [number, number][] = [] rtl = false max = 0 size = 0 scrollSize = 0 lastIntent = -Infinity pendingTarget: number | null = null snapshot = initialSnapshot subscribers = new Set<() => void>() listeners = new Map<CarouselEvent, Set<CarouselListener>>() frame = 0 settleTimer: ReturnType<typeof setTimeout> | null = null autoplayTimer: ReturnType<typeof setTimeout> | null = null remaining = defaultDelay startedAt = 0 holds = { hover: false, focus: false, touch: false, hidden: false, offscreen: false, } drag: DragState | null = null measured = false api: CarouselApi constructor(options: CarouselOptions) { this.options = options this.remaining = options.delay this.api = { scrollPrev: (jump) => this.step(-1, jump), scrollNext: (jump) => this.step(1, jump), scrollTo: (index, jump) => this.scrollTo(index, jump), scrollToSlide: (slideIndex, jump) => this.scrollTo(this.slideSnaps[slideIndex] ?? 0, jump), canScrollPrev: () => this.snapshot.canScrollPrev, canScrollNext: () => this.snapshot.canScrollNext, selectedScrollSnap: () => this.snapshot.selected, scrollSnapList: () => [...this.snaps], slidesInView: () => this.slides.length === 0 ? [] : Array.from( { length: this.snapshot.lastInView - this.snapshot.firstInView + 1, }, (_, offset) => this.snapshot.firstInView + offset ), slideNodes: () => [...this.slides], viewportNode: () => this.viewport, play: () => this.setPlaying(true), stop: () => this.setPlaying(false), isPlaying: () => this.snapshot.playing, on: (event, listener) => { const set = this.listeners.get(event) ?? new Set() set.add(listener) this.listeners.set(event, set) return this.api }, off: (event, listener) => { this.listeners.get(event)?.delete(listener) return this.api }, } } setOptions(options: CarouselOptions) { const orientationChanged = options.orientation !== this.options.orientation const delayChanged = options.delay !== this.options.delay this.options = options if (delayChanged) { this.restartAutoplay() } if (orientationChanged) { this.measure() } else if (this.measured) { this.update() } } subscribe = (callback: () => void) => { this.subscribers.add(callback) return () => { this.subscribers.delete(callback) } } getSnapshot = () => this.snapshot getServerSnapshot = () => initialSnapshot emit(event: CarouselEvent) { this.listeners.get(event)?.forEach((listener) => listener(this.api, event)) } setSnapshot(patch: Partial<CarouselSnapshot>) { const next = { ...this.snapshot, ...patch } const changed = (Object.keys(patch) as (keyof CarouselSnapshot)[]).some( (key) => next[key] !== this.snapshot[key] ) if (!changed) { return } this.snapshot = next this.subscribers.forEach((callback) => callback()) } get horizontal() { return this.options.orientation === "horizontal" } position() { const viewport = this.viewport if (!viewport) { return 0 } return Math.abs(this.horizontal ? viewport.scrollLeft : viewport.scrollTop) } setPosition(position: number, smooth: boolean) { const viewport = this.viewport if (!viewport) { return } const behavior: ScrollBehavior = smooth ? "smooth" : "instant" if (this.horizontal) { viewport.scrollTo({ left: this.rtl ? -position : position, behavior }) } else { viewport.scrollTo({ top: position, behavior }) } } measure() { const viewport = this.viewport const container = this.container if (!viewport || !container) { return } const horizontal = this.horizontal if ((horizontal ? viewport.clientWidth : viewport.clientHeight) === 0) { return } const style = getComputedStyle(viewport) this.rtl = horizontal && style.direction === "rtl" const paddingEnd = parseFloat( horizontal ? style.scrollPaddingInlineEnd : style.scrollPaddingBlockEnd ) || 0 const box = viewport.getBoundingClientRect() const position = this.position() this.size = horizontal ? viewport.clientWidth : viewport.clientHeight this.scrollSize = horizontal ? viewport.scrollWidth : viewport.scrollHeight this.max = Math.max( 0, horizontal ? viewport.scrollWidth - viewport.clientWidth : viewport.scrollHeight - viewport.clientHeight ) this.slides = Array.from(container.children).filter( (node): node is HTMLElement => node instanceof HTMLElement && node.dataset.slot === "carousel-item" ) this.slideRanges = this.slides.map((slide) => { const rect = slide.getBoundingClientRect() const start = horizontal ? this.rtl ? box.right - rect.right : rect.left - box.left : rect.top - box.top const length = horizontal ? rect.width : rect.height return [start + position, start + position + length] }) const snaps: number[] = [] this.slideSnaps = this.slideRanges.map(([, end]) => { const target = clamp(end - (this.size - paddingEnd), 0, this.max) const existing = snaps.findIndex((snap) => Math.abs(snap - target) <= 1) if (existing !== -1) { return existing } snaps.push(target) return snaps.length - 1 }) if (snaps.length === 0) { snaps.push(0) } if (this.max <= 1) { snaps.splice(1) this.slideSnaps = this.slideSnaps.map(() => 0) } this.snaps = snaps const count = this.slides.length this.slides.forEach((slide, index) => { if (!slide.hasAttribute("aria-label") || "autoLabel" in slide.dataset) { slide.setAttribute("aria-label", `${index + 1} of ${count}`) slide.dataset.autoLabel = "" } }) viewport.toggleAttribute("data-scrollable", this.max > 1) if (!this.measured) { this.measured = true const initial = clamp( this.options.controlledIndex ?? this.options.defaultIndex, 0, snaps.length - 1 ) if (initial > 0) { this.setPosition(this.snaps[initial], false) } this.snapshot = { ...this.snapshot, selected: initial } this.update(initial) } else { const selected = clamp( this.pendingTarget ?? this.snapshot.selected, 0, snaps.length - 1 ) if (Math.abs(this.position() - this.snaps[selected]) > 1 && !this.drag) { this.setPosition(this.snaps[selected], false) } this.update(selected) } this.syncAutoplay() this.emit("reInit") } layoutChanged() { const viewport = this.viewport if (!viewport) { return false } const size = this.horizontal ? viewport.clientWidth : viewport.clientHeight const scrollSize = this.horizontal ? viewport.scrollWidth : viewport.scrollHeight return size !== this.size || scrollSize !== this.scrollSize } progress() { const snaps = this.snaps if (snaps.length < 2) { return 0 } const position = this.position() for (let index = 0; index < snaps.length - 1; index++) { const from = snaps[index] const to = snaps[index + 1] if (position <= to) { return to > from ? index + clamp((position - from) / (to - from), 0, 1) : index } } return snaps.length - 1 } nearestSnap(position: number) { let nearest = 0 this.snaps.forEach((snap, index) => { if ( Math.abs(snap - position) < Math.abs(this.snaps[nearest] - position) ) { nearest = index } }) return nearest } inView(position: number) { let first = -1 let last = -1 this.slideRanges.forEach(([start, end], index) => { const visible = Math.min(end, position + this.size) - Math.max(start, position) if (visible >= Math.min(end - start, this.size) * 0.5) { if (first === -1) { first = index } last = index } }) return first === -1 ? [0, 0] : [first, last] } update(forced?: number) { const position = this.position() const selected = forced ?? this.pendingTarget ?? this.nearestSnap(position) const [firstInView, lastInView] = this.inView(position) const multiple = this.snaps.length > 1 const previous = this.snapshot.selected this.setSnapshot({ selected, snapCount: this.snaps.length, slideCount: this.slides.length, firstInView, lastInView, canScrollPrev: multiple && (this.options.rewind || selected > 0), canScrollNext: multiple && (this.options.rewind || selected < this.snaps.length - 1), }) if (selected !== previous) { this.restartAutoplay() this.emit("select") this.options.onIndexChange?.(selected) } } markIntent = () => { this.lastIntent = performance.now() } intended() { return ( this.pendingTarget !== null || this.drag !== null || performance.now() - this.lastIntent < intentWindow ) } realign() { const snap = this.snaps[this.snapshot.selected] if (snap !== undefined && Math.abs(this.position() - snap) > 1) { this.setPosition(snap, false) } } onScroll = () => { if (this.intended()) { this.markIntent() } if (!this.frame) { this.frame = requestAnimationFrame(() => { this.frame = 0 if (this.layoutChanged()) { this.measure() return } if (!this.intended()) { this.realign() return } this.update() this.emit("scroll") }) } if (typeof window !== "undefined" && !("onscrollend" in window)) { if (this.settleTimer) { clearTimeout(this.settleTimer) } this.settleTimer = setTimeout(this.onSettle, settleFallback) } } onSettle = () => { if (this.drag) { return } this.settleTimer = null const intended = this.intended() this.pendingTarget = null this.viewport?.removeAttribute("data-dragging") if (!intended) { this.realign() return } this.update() const { firstInView, lastInView, slideCount } = this.snapshot if (!this.snapshot.running && slideCount > 0) { this.setSnapshot({ announcement: firstInView === lastInView ? `Slide ${firstInView + 1} of ${slideCount}` : `Slides ${firstInView + 1} to ${lastInView + 1} of ${slideCount}`, }) } this.emit("settle") const controlled = this.options.controlledIndex if ( controlled !== undefined && controlled !== this.snapshot.selected && this.snaps.length > 0 ) { this.scrollTo(controlled) } } scrollTo(index: number, jump = false) { if (this.snaps.length === 0) { return } const target = clamp(Math.round(index) || 0, 0, this.snaps.length - 1) const position = this.snaps[target] this.pendingTarget = target this.update(target) if (Math.abs(this.position() - position) <= 1) { this.onSettle() return } this.setPosition(position, !jump && !prefersReducedMotion()) if (typeof window !== "undefined" && !("onscrollend" in window)) { if (this.settleTimer) { clearTimeout(this.settleTimer) } this.settleTimer = setTimeout(this.onSettle, 700) } } step(direction: 1 | -1, jump?: boolean) { const count = this.snaps.length if (count < 2) { return } const next = this.snapshot.selected + direction if (next < 0 || next >= count) { if (this.options.rewind) { this.scrollTo(next < 0 ? count - 1 : 0, jump) } return } this.scrollTo(next, jump) } setPlaying(playing: boolean) { this.remaining = this.options.delay this.setSnapshot({ playing, cycle: this.snapshot.cycle + 1 }) this.stopTimer() this.syncAutoplay() } setHold(key: keyof CarouselStore["holds"], value: boolean) { if (this.holds[key] === value) { return } this.holds[key] = value this.syncAutoplay() } stopTimer() { if (this.autoplayTimer) { clearTimeout(this.autoplayTimer) this.autoplayTimer = null this.remaining = Math.max( 0, this.remaining - (Date.now() - this.startedAt) ) } } remainingNow() { return this.autoplayTimer ? Math.max(0, this.remaining - (Date.now() - this.startedAt)) : this.remaining } syncAutoplay() { const held = Object.values(this.holds).some(Boolean) const running = this.snapshot.playing && !held && this.snaps.length > 1 && this.measured if (running && !this.autoplayTimer) { this.startedAt = Date.now() this.autoplayTimer = setTimeout(this.advance, this.remaining) } else if (!running) { this.stopTimer() } this.setSnapshot({ running }) } restartAutoplay() { if (!this.snapshot.playing) { return } if (this.autoplayTimer) { clearTimeout(this.autoplayTimer) this.autoplayTimer = null } this.remaining = this.options.delay this.setSnapshot({ cycle: this.snapshot.cycle + 1 }) this.syncAutoplay() } advance = () => { this.autoplayTimer = null this.remaining = this.options.delay const count = this.snaps.length const next = this.snapshot.selected + 1 >= count ? 0 : this.snapshot.selected + 1 this.scrollTo(next) this.setSnapshot({ cycle: this.snapshot.cycle + 1 }) this.syncAutoplay() } dragPosition(event: PointerEvent, drag: DragState) { const coordinate = this.horizontal ? event.clientX : event.clientY const delta = coordinate - drag.start return drag.startPosition + (this.rtl ? delta : -delta) } onPointerDown = (event: PointerEvent) => { this.markIntent() const viewport = this.viewport if (!viewport) { return } const target = event.target instanceof Element ? event.target : null if (target?.closest("[data-slot=carousel-content]") !== viewport) { return } if (event.pointerType !== "mouse") { this.pendingTarget = null this.setHold("touch", true) return } if ( event.button !== 0 || !this.options.mouseDrag || this.max <= 1 || isEditable(event.target) ) { return } this.pendingTarget = null this.drag = { pointerId: event.pointerId, start: this.horizontal ? event.clientX : event.clientY, startPosition: this.position(), lastPosition: this.position(), lastTime: event.timeStamp, velocity: 0, active: false, } window.addEventListener("pointermove", this.onPointerMove) window.addEventListener("pointerup", this.onPointerUp) window.addEventListener("pointercancel", this.onPointerUp) } onPointerMove = (event: PointerEvent) => { const drag = this.drag const viewport = this.viewport if (!drag || !viewport || event.pointerId !== drag.pointerId) { return } const position = this.dragPosition(event, drag) if (!drag.active) { if (Math.abs(position - drag.startPosition) < dragThreshold) { return } drag.active = true viewport.setAttribute("data-dragging", "") window.getSelection()?.removeAllRanges() this.setHold("touch", true) } const clamped = clamp(position, 0, this.max) const elapsed = Math.max(1, event.timeStamp - drag.lastTime) drag.velocity = drag.velocity * 0.4 + ((clamped - drag.lastPosition) / elapsed) * 0.6 drag.lastPosition = clamped drag.lastTime = event.timeStamp this.setPosition(clamped, false) } onPointerUp = (event: PointerEvent) => { const drag = this.drag if (!drag || event.pointerId !== drag.pointerId) { return } this.removeDragListeners() this.drag = null if (!drag.active) { return } this.setHold("touch", false) const viewport = this.viewport viewport?.addEventListener("click", this.suppressClick, { capture: true, once: true, }) setTimeout( () => viewport?.removeEventListener("click", this.suppressClick, true), 0 ) const velocity = event.timeStamp - drag.lastTime > 80 ? 0 : drag.velocity const projected = drag.lastPosition + velocity * 180 const start = this.nearestSnap(drag.startPosition) let target = this.nearestSnap(projected) const moved = drag.lastPosition - drag.startPosition if (target === start && Math.abs(moved) > Math.min(40, this.size * 0.1)) { target = clamp(start + Math.sign(moved), 0, this.snaps.length - 1) } this.scrollTo(target) if (this.pendingTarget === null) { viewport?.removeAttribute("data-dragging") } } suppressClick = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() } onTouchEnd = () => { this.setHold("touch", false) } removeDragListeners() { window.removeEventListener("pointermove", this.onPointerMove) window.removeEventListener("pointerup", this.onPointerUp) window.removeEventListener("pointercancel", this.onPointerUp) } onDragStart = (event: DragEvent) => { if (this.options.mouseDrag && this.max > 1) { event.preventDefault() } } onWheel = () => { this.markIntent() this.pendingTarget = null } attachViewport = (viewport: HTMLElement | null) => { if (!viewport) { return } this.viewport = viewport viewport.addEventListener("scroll", this.onScroll, { passive: true }) viewport.addEventListener("scrollend", this.onSettle) viewport.addEventListener("pointerdown", this.onPointerDown) viewport.addEventListener("pointerup", this.onTouchEnd) viewport.addEventListener("pointercancel", this.onTouchEnd) viewport.addEventListener("dragstart", this.onDragStart) viewport.addEventListener("wheel", this.onWheel, { passive: true }) const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => this.measure()) resizeObserver?.observe(viewport) if (this.container) { resizeObserver?.observe(this.container) } const mutationObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(() => this.measure()) if (this.container) { mutationObserver?.observe(this.container, { childList: true }) } document.fonts?.addEventListener?.("loadingdone", this.remeasure) this.measure() return () => { viewport.removeEventListener("scroll", this.onScroll) viewport.removeEventListener("scrollend", this.onSettle) viewport.removeEventListener("pointerdown", this.onPointerDown) viewport.removeEventListener("pointerup", this.onTouchEnd) viewport.removeEventListener("pointercancel", this.onTouchEnd) viewport.removeEventListener("dragstart", this.onDragStart) viewport.removeEventListener("wheel", this.onWheel) document.fonts?.removeEventListener?.("loadingdone", this.remeasure) resizeObserver?.disconnect() mutationObserver?.disconnect() this.removeDragListeners() this.drag = null if (this.frame) { cancelAnimationFrame(this.frame) this.frame = 0 } if (this.settleTimer) { clearTimeout(this.settleTimer) this.settleTimer = null } if (this.viewport === viewport) { this.viewport = null this.measured = false } } } remeasure = () => this.measure() attachContainer = (container: HTMLElement | null) => { if (!container) { return } this.container = container return () => { if (this.container === container) { this.container = null } } } onPointerEnter = (event: PointerEvent) => { if (event.pointerType === "mouse") { this.setHold("hover", true) } } onPointerLeave = () => { this.setHold("hover", false) } onFocusIn = (event: FocusEvent) => { this.markIntent() const target = event.target this.setHold( "focus", target instanceof Element && target.matches(":focus-visible") ) } onFocusOut = (event: FocusEvent) => { const next = event.relatedTarget if (!(next instanceof Node) || !this.root?.contains(next)) { this.setHold("focus", false) } } onVisibilityChange = () => { this.setHold("hidden", document.visibilityState === "hidden") } attachRoot = (root: HTMLElement | null) => { if (!root) { return } this.root = root root.addEventListener("pointerenter", this.onPointerEnter) root.addEventListener("pointerleave", this.onPointerLeave) root.addEventListener("focusin", this.onFocusIn) root.addEventListener("focusout", this.onFocusOut) document.addEventListener("visibilitychange", this.onVisibilityChange) this.onVisibilityChange() const intersectionObserver = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(([entry]) => this.setHold("offscreen", !entry.isIntersecting) ) intersectionObserver?.observe(root) return () => { root.removeEventListener("pointerenter", this.onPointerEnter) root.removeEventListener("pointerleave", this.onPointerLeave) root.removeEventListener("focusin", this.onFocusIn) root.removeEventListener("focusout", this.onFocusOut) document.removeEventListener("visibilitychange", this.onVisibilityChange) intersectionObserver?.disconnect() if (this.root === root) { this.root = null } } } destroy() { if (this.autoplayTimer) { clearTimeout(this.autoplayTimer) this.autoplayTimer = null } if (this.settleTimer) { clearTimeout(this.settleTimer) this.settleTimer = null } this.removeDragListeners() } } type CarouselContextValue = { store: CarouselStore snapshot: CarouselSnapshot orientation: CarouselOrientation delay: number } const CarouselContext = React.createContext<CarouselContextValue | null>(null) function useCarouselContext(part: string) { const context = React.useContext(CarouselContext) if (!context) { throw new Error(`<${part}> must be used within <Carousel>.`) } return context } function useCarousel() { const context = React.useContext(CarouselContext) if (!context) { throw new Error("useCarousel must be used within <Carousel>.") } const { store, snapshot, orientation } = context return { api: store.api, orientation, selectedIndex: snapshot.selected, snapCount: snapshot.snapCount, slideCount: snapshot.slideCount, slidesInView: [snapshot.firstInView, snapshot.lastInView] as const, canScrollPrev: snapshot.canScrollPrev, canScrollNext: snapshot.canScrollNext, isPlaying: snapshot.playing, scrollPrev: store.api.scrollPrev, scrollNext: store.api.scrollNext, scrollTo: store.api.scrollTo, scrollToSlide: store.api.scrollToSlide, play: store.api.play, stop: store.api.stop, } } const carouselVariants = cva("relative", { variants: { spacing: { none: "[--carousel-spacing:0px]", sm: "[--carousel-spacing:calc(var(--spacing)*2)]", default: "[--carousel-spacing:calc(var(--spacing)*4)]", lg: "[--carousel-spacing:calc(var(--spacing)*6)]", }, }, defaultVariants: { spacing: "default", }, }) type CarouselProps = useRender.ComponentProps<"div"> & VariantProps<typeof carouselVariants> & { orientation?: CarouselOrientation index?: number defaultIndex?: number onIndexChange?: (index: number) => void rewind?: boolean mouseDrag?: boolean autoplay?: CarouselAutoplay setApi?: (api: CarouselApi) => void } function Carousel({ className, orientation = "horizontal", spacing = "default", index, defaultIndex = 0, onIndexChange, rewind = false, mouseDrag = true, autoplay = false, setApi, render, ref, onKeyDown, children, ...props }: CarouselProps) { const delay = typeof autoplay === "object" ? Math.max(1000, autoplay.delay ?? defaultDelay) : defaultDelay const [store] = React.useState( () => new CarouselStore({ orientation, rewind, mouseDrag, delay, controlledIndex: index, defaultIndex, onIndexChange, }) ) const snapshot = React.useSyncExternalStore( store.subscribe, store.getSnapshot, store.getServerSnapshot ) React.useLayoutEffect(() => { store.setOptions({ orientation, rewind, mouseDrag, delay, controlledIndex: index, defaultIndex, onIndexChange, }) }) React.useEffect(() => { if ( index !== undefined && store.measured && index !== store.snapshot.selected && store.pendingTarget === null ) { store.scrollTo(index) } }, [index, store]) const autoplayEnabled = Boolean(autoplay) React.useEffect(() => { store.setPlaying(autoplayEnabled && !prefersReducedMotion()) if (!autoplayEnabled || typeof window.matchMedia !== "function") { return } const query = window.matchMedia("(prefers-reduced-motion: reduce)") const onChange = () => { if (query.matches) { store.setPlaying(false) } } query.addEventListener("change", onChange) return () => query.removeEventListener("change", onChange) }, [autoplayEnabled, store]) React.useEffect(() => { setApi?.(store.api) }, [setApi, store]) React.useEffect(() => () => store.destroy(), [store]) const rootRef = React.useCallback( (node: HTMLDivElement | null) => store.attachRoot(node), [store] ) const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { store.markIntent() onKeyDown?.(event) if ( event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || isEditable(event.target) || (event.target as Element).closest?.("[data-slot=carousel]") !== event.currentTarget ) { return } const rtl = getComputedStyle(event.currentTarget).direction === "rtl" const keys = orientation === "horizontal" ? rtl ? { prev: "ArrowRight", next: "ArrowLeft" } : { prev: "ArrowLeft", next: "ArrowRight" } : { prev: "ArrowUp", next: "ArrowDown" } if (event.key === keys.prev) { event.preventDefault() store.step(-1) } else if (event.key === keys.next) { event.preventDefault() store.step(1) } } const element = useRender({ defaultTagName: "div", render, ref: ref ? [ref, rootRef] : rootRef, props: mergeProps<"div">( { role: "region", "aria-roledescription": "carousel", className: cn(carouselVariants({ spacing }), className), onKeyDown: handleKeyDown, children: ( <> {children} <span data-slot="carousel-status" role="status" className="sr-only"> {snapshot.announcement} </span> </> ), }, props, { "data-slot": "carousel", "data-orientation": orientation, } as Record<string, string> ), }) return ( <CarouselContext.Provider value={{ store, snapshot, orientation, delay }}> {element} </CarouselContext.Provider> ) } function CarouselContent({ className, viewportClassName, ...props }: React.ComponentProps<"div"> & { viewportClassName?: string }) { const { store, orientation } = useCarouselContext("CarouselContent") const viewportRef = React.useCallback( (node: HTMLDivElement | null) => store.attachViewport(node), [store] ) const containerRef = React.useCallback( (node: HTMLDivElement | null) => store.attachContainer(node), [store] ) return ( <div ref={viewportRef} tabIndex={0} data-slot="carousel-content" data-orientation={orientation} className={cn( "-m-1 no-scrollbar scroll-p-1 p-1 outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden data-dragging:cursor-grabbing data-dragging:snap-none data-dragging:select-none [@media(pointer:fine)]:data-scrollable:cursor-grab", orientation === "horizontal" ? "snap-x snap-mandatory overflow-x-auto overflow-y-hidden overscroll-x-none" : "snap-y snap-mandatory overflow-x-hidden overflow-y-auto overscroll-y-none", viewportClassName )} > <div ref={containerRef} data-slot="carousel-container" className={cn( "flex after:shrink-0 after:basis-1 after:content-['']", orientation === "horizontal" ? "-ms-(--carousel-spacing)" : "-mt-(--carousel-spacing) flex-col", className )} {...props} /> </div> ) } function CarouselItem({ className, render, ...props }: useRender.ComponentProps<"div">) { const { orientation } = useCarouselContext("CarouselItem") return useRender({ defaultTagName: "div", render, props: mergeProps<"div">( { role: "group", "aria-roledescription": "slide", className: cn( "min-w-0 shrink-0 grow-0 basis-full snap-end", orientation === "horizontal" ? "ps-(--carousel-spacing)" : "pt-(--carousel-spacing)", className ), }, props, { "data-slot": "carousel-item" } as Record<string, string> ), }) } const carouselNavVariants = cva( "absolute rounded-full data-disabled:opacity-50", { variants: { orientation: { horizontal: "inset-y-0 my-auto", vertical: "inset-x-0 mx-auto", }, direction: { previous: "", next: "", }, }, compoundVariants: [ { orientation: "horizontal", direction: "previous", className: "-start-12", }, { orientation: "horizontal", direction: "next", className: "-end-12" }, { orientation: "vertical", direction: "previous", className: "-top-12" }, { orientation: "vertical", direction: "next", className: "-bottom-12" }, ], } ) type CarouselNavProps = React.ComponentProps<typeof Button> function useNavButton( part: string, direction: "previous" | "next", { className, onClick, disabled, ...props }: CarouselNavProps ) { const { store, snapshot, orientation } = useCarouselContext(part) const enabled = direction === "previous" ? snapshot.canScrollPrev : snapshot.canScrollNext return { ...props, disabled: disabled || !enabled, focusableWhenDisabled: true, className: cn(carouselNavVariants({ orientation, direction }), className), onClick: ( event: Parameters<NonNullable<CarouselNavProps["onClick"]>>[0] ) => { const result = onClick?.(event) if (!event.defaultPrevented) { store.step(direction === "previous" ? -1 : 1) } return result }, orientation, } } function CarouselPrevious({ variant = "outline", size = "icon-sm", children, ...props }: CarouselNavProps) { const { orientation, ...buttonProps } = useNavButton( "CarouselPrevious", "previous", props ) return ( <Button data-slot="carousel-previous" aria-label="Previous slide" variant={variant} size={size} {...buttonProps} > {children ?? (orientation === "horizontal" ? ( <IconArrowLeft className="rtl:-scale-x-100" /> ) : ( <IconArrowUp /> ))} </Button> ) } function CarouselNext({ variant = "outline", size = "icon-sm", children, ...props }: CarouselNavProps) { const { orientation, ...buttonProps } = useNavButton( "CarouselNext", "next", props ) return ( <Button data-slot="carousel-next" aria-label="Next slide" variant={variant} size={size} {...buttonProps} > {children ?? (orientation === "horizontal" ? ( <IconArrowRight className="rtl:-scale-x-100" /> ) : ( <IconArrowDown /> ))} </Button> ) } function useFocusFollowsSelection( containerRef: React.RefObject<HTMLElement | null>, selector: string, key: string ) { React.useLayoutEffect(() => { const container = containerRef.current if (!container || !container.contains(document.activeElement)) { return } const active = container.querySelector<HTMLElement>(selector) if (active && active !== document.activeElement) { active.focus({ preventScroll: true }) } }, [containerRef, selector, key]) } function AutoplayProgress({ vertical }: { vertical: boolean }) { const { store, snapshot, delay } = useCarouselContext("CarouselDots") const fillRef = React.useRef<HTMLSpanElement>(null) const { cycle, running, selected } = snapshot React.useLayoutEffect(() => { const fill = fillRef.current if (!fill || typeof fill.animate !== "function") { return } const animation = fill.animate( vertical ? [{ transform: "scaleY(0)" }, { transform: "scaleY(1)" }] : [{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }], { duration: delay, fill: "forwards" } ) animation.currentTime = delay - store.remainingNow() if (!running) { animation.pause() } return () => animation.cancel() }, [cycle, running, selected, delay, store, vertical]) return ( <span ref={fillRef} aria-hidden className={cn( "absolute inset-0 rounded-[inherit] bg-foreground", vertical ? "origin-top" : "origin-left rtl:origin-right" )} /> ) } const dotJumpDuration = 200 function useDotProgress( containerRef: React.RefObject<HTMLElement | null>, store: CarouselStore, snapCount: number ) { React.useLayoutEffect(() => { const container = containerRef.current if (!container) { return } let previous: number | null = null let jumpTimer: ReturnType<typeof setTimeout> | null = null const sync = () => { const progress = store.progress() if (previous !== null && Math.abs(progress - previous) > 0.5) { container.setAttribute("data-jumping", "") if (jumpTimer) { clearTimeout(jumpTimer) } jumpTimer = setTimeout(() => { container.removeAttribute("data-jumping") jumpTimer = null }, dotJumpDuration) } previous = progress container .querySelectorAll<HTMLElement>(":scope > [data-slot=carousel-dot]") .forEach((dot, index) => { const active = clamp(1 - Math.abs(progress - index), 0, 1) dot.style.setProperty("--dot-active", String(active)) }) } sync() const events: CarouselEvent[] = ["scroll", "select", "settle", "reInit"] events.forEach((event) => store.api.on(event, sync)) return () => { events.forEach((event) => store.api.off(event, sync)) if (jumpTimer) { clearTimeout(jumpTimer) } container.removeAttribute("data-jumping") } }, [containerRef, store, snapCount]) } function CarouselDots({ className, "aria-label": ariaLabel = "Choose slide", ...props }: React.ComponentProps<"div">) { const { store, snapshot, orientation } = useCarouselContext("CarouselDots") const containerRef = React.useRef<HTMLDivElement>(null) const vertical = orientation === "vertical" useFocusFollowsSelection( containerRef, "[data-slot=carousel-dot][aria-current]", String(snapshot.selected) ) useDotProgress(containerRef, store, snapshot.snapCount) return ( <div ref={containerRef} role="group" aria-label={ariaLabel} data-slot="carousel-dots" data-orientation={orientation} className={cn( "flex max-h-full min-h-0 max-w-full min-w-0 items-center justify-center", vertical ? "min-w-6 flex-col" : "min-h-6", snapshot.playing ? "[--dot-fill:0]" : "[--dot-fill:1]", className )} {...props} > {Array.from( { length: snapshot.snapCount > 1 ? snapshot.snapCount : 0 }, (_, index) => { const active = index === snapshot.selected return ( <button key={index} type="button" data-slot="carousel-dot" aria-label={`Go to slide ${index + 1}`} aria-current={active || undefined} tabIndex={active ? 0 : -1} onClick={() => store.scrollTo(index)} className={cn( "group/dot relative flex shrink cursor-pointer items-center justify-center rounded-full outline-none [--dot-active:0] [--dot-floor:20%] [-webkit-tap-highlight-color:transparent] focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden in-data-jumping:transition-[width,height,min-width,min-height] in-data-jumping:duration-200 in-data-jumping:ease-spring aria-[current=true]:[--dot-active:1] motion-reduce:in-data-jumping:duration-0 pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2.5 [@media(hover:hover)]:hover:[--dot-floor:45%]", vertical ? "h-[calc(1.5rem+0.25rem*var(--dot-active))] min-h-[calc(0.625rem+1.125rem*var(--dot-active))] w-6" : "h-6 w-[calc(1.5rem+0.25rem*var(--dot-active))] min-w-[calc(0.625rem+1.125rem*var(--dot-active))]" )} > <span data-slot="carousel-dot-indicator" className={cn( "relative overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_max(var(--dot-floor),calc(20%+80%*var(--dot-active)*var(--dot-fill))),transparent)] in-data-jumping:transition-[width,height,background-color] in-data-jumping:duration-200 in-data-jumping:ease-spring motion-reduce:in-data-jumping:duration-0", vertical ? "h-[calc(0.375rem+0.875rem*var(--dot-active))] w-1.5" : "h-1.5 w-[calc(0.375rem+0.875rem*var(--dot-active))]" )} > {active && snapshot.playing ? ( <AutoplayProgress vertical={vertical} /> ) : null} </span> </button> ) } )} </div> ) } function CarouselCounter({ className, ...props }: Omit<React.ComponentProps<"div">, "children">) { const { snapshot } = useCarouselContext("CarouselCounter") return ( <div data-slot="carousel-counter" dir="ltr" className={cn( "inline-flex min-h-5 items-center gap-1 text-sm text-muted-foreground tabular-nums", className )} {...props} > {snapshot.slideCount > 0 ? ( <> <NumberFlow value={snapshot.selected + 1} className="text-foreground" /> <span aria-hidden>/</span> <span className="sr-only">of</span> <span>{snapshot.snapCount}</span> </> ) : null} </div> ) } function CarouselAutoplayToggle({ variant = "ghost", size = "icon-sm", onClick, children, ...props }: React.ComponentProps<typeof Button>) { const { store, snapshot } = useCarouselContext("CarouselAutoplayToggle") return ( <Button data-slot="carousel-autoplay-toggle" aria-label={snapshot.playing ? "Pause slideshow" : "Play slideshow"} variant={variant} size={size} onClick={(event) => { const result = onClick?.(event) if (!event.defaultPrevented) { store.setPlaying(!store.snapshot.playing) } return result }} {...props} > {children ?? (snapshot.playing ? <IconPlayerPause /> : <IconPlayerPlay />)} </Button> ) } const ThumbnailIndexContext = React.createContext<number | null>(null) function CarouselThumbnails({ className, children, "aria-label": ariaLabel = "Slides", ...props }: React.ComponentProps<"div">) { const { snapshot } = useCarouselContext("CarouselThumbnails") const containerRef = React.useRef<HTMLDivElement>(null) useFocusFollowsSelection( containerRef, "[data-slot=carousel-thumbnail][data-active]", `${snapshot.firstInView}-${snapshot.lastInView}` ) React.useLayoutEffect(() => { const strip = containerRef.current const active = strip?.querySelector<HTMLElement>( "[data-slot=carousel-thumbnail][data-active]" ) if (!strip || !active || strip.scrollWidth <= strip.clientWidth) { return } const stripRect = strip.getBoundingClientRect() const rect = active.getBoundingClientRect() const inset = 8 let delta = 0 if (rect.left < stripRect.left + inset) { delta = rect.left - stripRect.left - inset } else if (rect.right > stripRect.right - inset) { delta = rect.right - stripRect.right + inset } if (delta !== 0) { strip.scrollBy({ left: delta, behavior: prefersReducedMotion() ? "instant" : "smooth", }) } }, [snapshot.firstInView, snapshot.lastInView]) return ( <div ref={containerRef} role="group" aria-label={ariaLabel} data-slot="carousel-thumbnails" className={cn( "-m-1 no-scrollbar flex max-w-full min-w-0 gap-2 overflow-x-auto overscroll-x-none p-1", className )} {...props} > {React.Children.map(children, (child, index) => ( <ThumbnailIndexContext.Provider value={index}> {child} </ThumbnailIndexContext.Provider> ))} </div> ) } type CarouselThumbnailProps = useRender.ComponentProps<"button"> & { index?: number } function CarouselThumbnail({ className, index: indexProp, render, onClick, ...props }: CarouselThumbnailProps) { const { store, snapshot } = useCarouselContext("CarouselThumbnail") const contextIndex = React.useContext(ThumbnailIndexContext) const index = indexProp ?? contextIndex ?? 0 const active = snapshot.slideCount > 0 ? index >= snapshot.firstInView && index <= snapshot.lastInView : index === 0 const focusTarget = index === (snapshot.slideCount > 0 ? snapshot.firstInView : 0) return useRender({ defaultTagName: "button", render, props: mergeProps<"button">( { type: "button", "aria-label": `Go to slide ${index + 1}`, "aria-current": active || undefined, tabIndex: focusTarget ? 0 : -1, onClick: (event) => { onClick?.(event) if (!event.defaultPrevented) { store.api.scrollToSlide(index) } }, className: cn( "relative shrink-0 cursor-pointer overflow-hidden rounded-md opacity-60 transition-opacity duration-200 ease-out-quint outline-none [-webkit-tap-highlight-color:transparent] after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:ring-2 after:ring-transparent after:transition-[box-shadow] after:duration-200 after:ring-inset hover:opacity-100 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden data-active:opacity-100 data-active:after:ring-foreground", className ), }, props, { "data-slot": "carousel-thumbnail", ...(active ? { "data-active": "" } : {}), } as Record<string, string> ), }) } export { Carousel, CarouselAutoplayToggle, CarouselContent, CarouselCounter, CarouselDots, CarouselItem, CarouselNext, CarouselPrevious, CarouselThumbnail, CarouselThumbnails, carouselVariants, useCarousel, } export type { CarouselApi, CarouselAutoplay, CarouselEvent, CarouselOrientation, CarouselProps, CarouselThumbnailProps, }components/ui/button.tsx "use client" import * as React from "react" import { Button as ButtonPrimitive } from "@base-ui/react/button" import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Spinner } from "@/components/ui/spinner" import { useButtonFeedback, type ButtonFeedbackOptions, type ButtonStatus, } from "@/hooks/use-button-feedback" const buttonVariants = cva( "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70", link: "text-primary underline-offset-4 hover:underline forced-colors:border-0", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40", error: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake", }, size: { default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5", lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", icon: "size-9 shrink-0", "icon-xs": "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden", "icon-lg": "size-10 shrink-0", }, }, defaultVariants: { variant: "default", size: "default", }, } ) type ButtonVariant = Exclude< NonNullable<VariantProps<typeof buttonVariants>["variant"]>, "success" | "error" > type ButtonClickEvent = Parameters< NonNullable<ButtonPrimitive.Props["onClick"]> >[0] type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> & Omit<VariantProps<typeof buttonVariants>, "variant"> & ButtonFeedbackOptions & { variant?: ButtonVariant | null onClick?: (event: ButtonClickEvent) => unknown feedback?: boolean loading?: boolean status?: ButtonStatus loadingLabel?: React.ReactNode successLabel?: React.ReactNode errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode) } const stateTransitionTime = 220 const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"] const quietVariants: ButtonVariant[] = ["ghost", "link"] const quietStatusClasses: Partial<Record<ButtonStatus, string>> = { success: "text-success hover:text-success", error: "text-destructive hover:text-destructive motion-safe:animate-button-shake", } function isThenable(value: unknown): value is PromiseLike<unknown> { return ( typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" ) } function useStatusTransition(status: ButtonStatus) { const [shown, setShown] = React.useState(status) const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null) const [transitionKey, setTransitionKey] = React.useState(0) if (status !== shown) { setLeaving(shown) setShown(status) setTransitionKey((key) => key + 1) } React.useEffect(() => { if (leaving === null) { return } const timer = setTimeout(() => setLeaving(null), stateTransitionTime) return () => clearTimeout(timer) }, [leaving, transitionKey]) return { leaving, transitionKey } } function useLayerWidth(status: ButtonStatus, active: boolean) { const contentRef = React.useRef<HTMLSpanElement>(null) const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>()) const observerRef = React.useRef<ResizeObserver | null>(null) const statusRef = React.useRef(status) const applyWidth = React.useCallback(() => { const content = contentRef.current const layer = layersRef.current.get(statusRef.current) if (!content || !layer) { return } const natural = parseFloat(getComputedStyle(layer).width) if (!Number.isFinite(natural)) { return } content.style.width = `${natural}px` if (!content.hasAttribute("data-measured")) { requestAnimationFrame(() => content.setAttribute("data-measured", "")) } }, []) React.useLayoutEffect(() => { statusRef.current = status if (active) { applyWidth() } }, [active, applyWidth, status]) React.useLayoutEffect(() => { if (!active || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(applyWidth) observerRef.current = observer layersRef.current.forEach((layer) => observer.observe(layer)) document.fonts?.addEventListener?.("loadingdone", applyWidth) return () => { observer.disconnect() observerRef.current = null document.fonts?.removeEventListener?.("loadingdone", applyWidth) } }, [active, applyWidth]) const layerRef = React.useCallback((node: HTMLElement | null) => { if (!node) { return } const layer = node.dataset.layer as ButtonStatus layersRef.current.set(layer, node) observerRef.current?.observe(node) return () => { observerRef.current?.unobserve(node) if (layersRef.current.get(layer) === node) { layersRef.current.delete(layer) } } }, []) return { contentRef, layerRef } } function composeHandlers<Event>( first: ((event: Event) => void) | undefined, second: (event: Event) => void ) { return (event: Event) => { first?.(event) second(event) } } function Button({ className, variant = "default", size = "default", children, onClick, disabled, focusableWhenDisabled, feedback, loading, status: statusProp, onStatusChange, onError, resetAfter, loadingLabel, successLabel = "Done", errorLabel = "Failed", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: ButtonProps) { const internal = useButtonFeedback({ resetAfter, onStatusChange, onError }) const status = statusProp ?? (loading ? "loading" : internal.status) const requested = Boolean(feedback) || loading !== undefined || statusProp !== undefined const [enabled, setEnabled] = React.useState(requested) if (requested && !enabled) { setEnabled(true) } const { leaving, transitionKey } = useStatusTransition(status) const iconOnly = size?.startsWith("icon") ?? false const widthStatus = status === "loading" && loadingLabel === undefined ? "idle" : status const { contentRef, layerRef } = useLayerWidth( widthStatus, enabled && !iconOnly ) const isLoading = status === "loading" const quiet = quietVariants.includes(variant ?? "default") const resolvedVariant = !quiet && status === "success" ? "success" : !quiet && status === "error" ? "error" : variant const resolvedErrorLabel = typeof errorLabel !== "function" ? errorLabel : internal.error !== undefined ? errorLabel(internal.error) : "Failed" const handleClick = (event: ButtonClickEvent) => { if (feedback && internal.isPending()) { return } const result = onClick?.(event) if (feedback && isThenable(result)) { internal.track(result) } } const layerContent = (layer: ButtonStatus, animated: boolean) => { if (layer === "idle") { return children } if (layer === "loading") { return ( <> <Spinner aria-hidden size={null} animated={animated} /> {iconOnly ? null : loadingLabel} </> ) } if (layer === "success") { return ( <> <IconCircleCheck className={cn( animated && "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]" )} /> {iconOnly ? null : successLabel} </> ) } return ( <> <IconAlertCircle /> {iconOnly ? null : resolvedErrorLabel} </> ) } const button = ( <ButtonPrimitive data-slot="button" data-status={enabled ? status : undefined} aria-busy={isLoading || undefined} disabled={disabled || isLoading} focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)} className={cn( buttonVariants({ variant: resolvedVariant, size }), quiet && quietStatusClasses[status], className )} onClick={handleClick} onPointerEnter={composeHandlers( onPointerEnter, internal.buttonProps.onPointerEnter )} onPointerLeave={composeHandlers( onPointerLeave, internal.buttonProps.onPointerLeave )} onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)} onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)} {...props} > {enabled ? ( <span ref={contentRef} data-slot="button-content" className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]" > {statuses.map((layer) => { const active = layer === status const exiting = layer === leaving const namesButton = layer === "idle" && isLoading && loadingLabel === undefined return ( <span key={active ? `${layer}-${transitionKey}` : layer} ref={layerRef} data-slot="button-layer" data-layer={layer} aria-hidden={active || namesButton ? undefined : true} className={cn( "inline-flex items-center justify-center gap-[inherit] backface-hidden", !active && !exiting && "pointer-events-none absolute opacity-0", active && transitionKey > 0 && "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0", exiting && "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out" )} > {layerContent(layer, active || exiting)} </span> ) })} </span> ) : ( children )} </ButtonPrimitive> ) if (!enabled) { return button } const announcement = status === "loading" ? typeof loadingLabel === "string" ? loadingLabel : "Loading" : status === "success" ? typeof successLabel === "string" ? successLabel : "Done" : status === "error" ? typeof resolvedErrorLabel === "string" ? resolvedErrorLabel : "Failed" : "" return ( <> {button} <span data-slot="button-status" role="status" className="sr-only"> {announcement} </span> </> ) } export { Button, buttonVariants, useButtonFeedback } export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }components/ui/number-flow.tsx "use client" import * as React from "react" import { cn } from "cn" import { easeSpring } from "@/lib/motion" type NumberFlowTrend = "auto" | "up" | "down" | "shortest" type NumberFlowChar = { key: string section: number order: number value: string digit: number | null } type NumberFlowItem = NumberFlowChar & { exiting: boolean exitId: number } type NumberFlowProps = Omit< React.ComponentProps<"span">, "children" | "prefix" > & { value: number locales?: Intl.LocalesArgument format?: Intl.NumberFormatOptions prefix?: string suffix?: string trend?: NumberFlowTrend duration?: number easing?: string animated?: boolean onAnimationsStart?: () => void onAnimationsFinish?: () => void } const rows = 30 const restingClasses = "data-[digit='0']:translate-y-[-33.3333%] data-[digit='1']:translate-y-[-36.6667%] data-[digit='2']:translate-y-[-40%] data-[digit='3']:translate-y-[-43.3333%] data-[digit='4']:translate-y-[-46.6667%] data-[digit='5']:translate-y-[-50%] data-[digit='6']:translate-y-[-53.3333%] data-[digit='7']:translate-y-[-56.6667%] data-[digit='8']:translate-y-[-60%] data-[digit='9']:translate-y-[-63.3333%]" function getSpinDelta( from: number, to: number, trend: Exclude<NumberFlowTrend, "auto"> ) { let delta = to - from if (trend === "up") { if (delta < 0) { delta += 10 } } else if (trend === "down") { if (delta > 0) { delta -= 10 } } else if (delta > 5) { delta -= 10 } else if (delta < -4) { delta += 10 } return delta } function digitGlyphs( formatter: Intl.NumberFormat, locales: Intl.LocalesArgument ) { const { numberingSystem } = formatter.resolvedOptions() const plain = new Intl.NumberFormat(locales, { numberingSystem, useGrouping: false, }) return Array.from({ length: 10 }, (_, digit) => plain.format(digit)) } function formatChars( value: number, locales: Intl.LocalesArgument, format: Intl.NumberFormatOptions | undefined, prefix: string | undefined, suffix: string | undefined ) { const formatter = new Intl.NumberFormat(locales, format) const glyphs = digitGlyphs(formatter, locales) const parts = formatter.formatToParts(value) const integerCount = parts .filter((part) => part.type === "integer") .reduce((count, part) => count + Array.from(part.value).length, 0) const chars: NumberFlowChar[] = [] const occurrences = new Map<string, number>() let integerIndex = 0 let fractionIndex = 0 let seenNumber = false function symbol(section: number, type: string, text: string) { const name = `${section}-${type}` const occurrence = occurrences.get(name) ?? 0 occurrences.set(name, occurrence + 1) chars.push({ key: `s${name}-${occurrence}`, section, order: occurrence, value: text, digit: null, }) } if (prefix) { chars.push({ key: "prefix", section: 0, order: 0, value: prefix, digit: null, }) } for (const part of parts) { if (part.type === "integer") { seenNumber = true for (const glyph of Array.from(part.value)) { const place = integerCount - 1 - integerIndex const digit = glyphs.indexOf(glyph) chars.push({ key: `i${place}`, section: 2, order: -place, value: glyph, digit: digit === -1 ? null : digit, }) integerIndex += 1 } } else if (part.type === "group") { const place = integerCount - 1 - integerIndex chars.push({ key: `g${place}`, section: 2, order: -place - 0.5, value: part.value, digit: null, }) } else if (part.type === "decimal") { seenNumber = true chars.push({ key: "decimal", section: 3, order: 0, value: part.value, digit: null, }) } else if (part.type === "fraction") { for (const glyph of Array.from(part.value)) { const digit = glyphs.indexOf(glyph) chars.push({ key: `f${fractionIndex}`, section: 4, order: fractionIndex, value: glyph, digit: digit === -1 ? null : digit, }) fractionIndex += 1 } } else { symbol(seenNumber ? 5 : 1, part.type, part.value) } } if (suffix) { chars.push({ key: "suffix", section: 6, order: 0, value: suffix, digit: null, }) } return chars } function signature(chars: NumberFlowChar[]) { return chars.map((char) => `${char.key}:${char.value}`).join("|") } function mergeItems(previous: NumberFlowItem[], next: NumberFlowChar[]) { const keys = new Set(next.map((char) => char.key)) const kept = previous .filter((item) => !keys.has(item.key)) .map((item) => item.exiting ? item : { ...item, exiting: true, exitId: item.exitId + 1 } ) const fresh = next.map((char) => { const before = previous.find((item) => item.key === char.key) return { ...char, exiting: false, exitId: before?.exitId ?? 0 } }) return [...kept, ...fresh].sort( (a, b) => a.section - b.section || a.order - b.order ) } function subscribeReducedMotion(callback: () => void) { if (typeof window.matchMedia !== "function") { return () => {} } const query = window.matchMedia("(prefers-reduced-motion: reduce)") query.addEventListener("change", callback) return () => query.removeEventListener("change", callback) } function getReducedMotion() { return ( typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } function canAnimate() { return ( typeof Element !== "undefined" && typeof Element.prototype.animate === "function" ) } function liveIndex(column: HTMLElement) { const height = column.offsetHeight if (!height) { return null } const translate = getComputedStyle(column).translate const y = translate === "none" ? "0" : (translate.split(" ")[1] ?? "0") const amount = parseFloat(y) || 0 return y.endsWith("%") ? (-amount / 100) * rows : (-amount / height) * rows } function NumberFlow({ value, locales = "en-US", format, prefix, suffix, trend = "auto", duration = 600, easing = easeSpring, animated = true, onAnimationsStart, onAnimationsFinish, className, ...props }: NumberFlowProps) { const chars = formatChars(value, locales, format, prefix, suffix) const key = signature(chars) const reducedMotion = React.useSyncExternalStore( subscribeReducedMotion, getReducedMotion, () => false ) const animate = animated && !reducedMotion && canAnimate() const [state, setState] = React.useState(() => ({ key, items: chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), })) if (state.key !== key) { setState({ key, items: animate ? mergeItems(state.items, chars) : chars.map((char) => ({ ...char, exiting: false, exitId: 0 })), }) } const rootRef = React.useRef<HTMLSpanElement>(null) const previousRef = React.useRef<Map< string, { digit: number | null; exiting: boolean; exitId: number } > | null>(null) const valueRef = React.useRef(value) const batchRef = React.useRef(0) const callbacksRef = React.useRef({ onAnimationsStart, onAnimationsFinish }) const finishExitRef = React.useRef((itemKey: string, exitId: number) => { setState((current) => ({ ...current, items: current.items.filter( (item) => !(item.key === itemKey && item.exiting && item.exitId === exitId) ), })) }) React.useLayoutEffect(() => { callbacksRef.current = { onAnimationsStart, onAnimationsFinish } }) React.useLayoutEffect(() => { const root = rootRef.current const previous = previousRef.current const current = new Map( state.items.map((item) => [ item.key, { digit: item.digit, exiting: item.exiting, exitId: item.exitId }, ]) ) const previousValue = valueRef.current previousRef.current = current valueRef.current = value if (!root || !previous || !animate) { return } const direction = trend !== "auto" ? trend : value > previousValue ? "up" : value < previousValue ? "down" : "shortest" const timing = { duration, easing } const started: Animation[] = [] const cells = root.querySelectorAll<HTMLElement>( ":scope > [data-number-flow-key]" ) for (const cell of cells) { const itemKey = cell.dataset.numberFlowKey! const item = current.get(itemKey) const before = previous.get(itemKey) if (!item || typeof cell.animate !== "function") { continue } const column = cell.querySelector<HTMLElement>( "[data-slot=number-flow-column]" ) const entering = !item.exiting && (!before || before.exiting) const leaving = item.exiting && (!before || !before.exiting) if (entering || leaving) { const running = cell.getAnimations() const fromWidth = running.length ? cell.getBoundingClientRect().width : entering ? 0 : cell.getBoundingClientRect().width const fromOpacity = running.length ? Number(getComputedStyle(cell).opacity) : entering ? 0 : 1 running.forEach((animation) => animation.cancel()) const toWidth = entering ? cell.getBoundingClientRect().width : 0 const animation = cell.animate( [ { width: `${fromWidth}px`, opacity: fromOpacity }, { width: `${toWidth}px`, opacity: entering ? 1 : 0 }, ], { ...timing, fill: leaving ? "forwards" : "none" } ) started.push(animation) if (leaving) { const exitId = item.exitId animation.finished.then( () => finishExitRef.current(itemKey, exitId), () => {} ) } } if (!column || item.digit === null || item.exiting) { continue } const target = item.digit const running = column.getAnimations() const live = running.length ? liveIndex(column) : null running.forEach((animation) => animation.cancel()) const from = live !== null ? live - 10 : entering ? 0 : before && before.digit !== null ? before.digit : target if (live === null && from === target && !entering) { continue } const delta = getSpinDelta( from, target, entering && live === null ? "up" : direction ) if (delta === 0) { continue } started.push( column.animate( [ { translate: `0 ${(-(target + 10 - delta) / rows) * 100}%` }, { translate: `0 ${(-(target + 10) / rows) * 100}%` }, ], timing ) ) } if (!started.length) { return } batchRef.current += 1 const batch = batchRef.current callbacksRef.current.onAnimationsStart?.() Promise.all(started.map((animation) => animation.finished)).then( () => { if (batch === batchRef.current) { callbacksRef.current.onAnimationsFinish?.() } }, () => {} ) }, [state.items, animate, duration, easing, trend, value]) return ( <span ref={rootRef} dir="ltr" className={cn("inline-block max-w-full tabular-nums", className)} {...props} data-slot="number-flow" > <span data-slot="number-flow-text" className="sr-only"> {state.items .filter((item) => !item.exiting) .map((item) => item.value) .join("")} </span> {state.items.map((item) => item.digit === null ? ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-symbol" aria-hidden className="inline-block max-w-full overflow-clip wrap-anywhere whitespace-pre-wrap" > {item.value} </span> ) : ( <span key={item.key} data-number-flow-key={item.key} data-slot="number-flow-digit" aria-hidden className="relative -my-[0.2em] inline-block overflow-clip [mask-image:linear-gradient(to_bottom,transparent,black_0.2em,black_calc(100%-0.2em),transparent)] py-[0.2em]" > <span className="opacity-0">{item.value}</span> <span aria-hidden data-slot="number-flow-column" data-digit={item.digit} className={cn( "pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center select-none", restingClasses )} > {Array.from({ length: rows }, (_, row) => ( <span key={row} className="block py-[0.2em]"> {glyphFor(item, row)} </span> ))} </span> </span> ) )} </span> ) } function glyphFor(item: NumberFlowItem, row: number) { return String.fromCodePoint( item.value.codePointAt(0)! - (item.digit ?? 0) + (row % 10) ) } export { NumberFlow, getSpinDelta, formatChars, type NumberFlowProps, type NumberFlowTrend, }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel"<Carousel aria-label="Featured">
<CarouselContent>
<CarouselItem>...</CarouselItem>
<CarouselItem>...</CarouselItem>
<CarouselItem>...</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselDots />
</Carousel>Slides scroll natively with CSS scroll snap, so touch momentum and trackpad scrolling feel like the platform. A mouse can drag, and the arrow keys move one slide at a time.
Carousel
├── CarouselContent
│ └── CarouselItem
├── CarouselPrevious
├── CarouselNext
├── CarouselDots
├── CarouselCounter
├── CarouselAutoplayToggle
└── CarouselThumbnails
└── CarouselThumbnailAPI#
Pass setApi to get the carousel API, then listen for select to show your own position. Next is disabled on the last slide but keeps focus.
Slide 0 of 0
"use client"
import * as React from "react"
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
type CarouselApi,
} from "@/components/ui/carousel"
const slides = [1, 2, 3, 4, 5]
export function CarouselWithApi() {
const [api, setApi] = React.useState<CarouselApi>()
const [current, setCurrent] = React.useState(0)
const [count, setCount] = React.useState(0)
React.useEffect(() => {
if (!api) {
return
}
const sync = () => {
setCount(api.scrollSnapList().length)
setCurrent(api.selectedScrollSnap() + 1)
}
sync()
api.on("select", sync).on("reInit", sync)
return () => {
api.off("select", sync).off("reInit", sync)
}
}, [api])
return (
<div className="flex w-full max-w-xs flex-col items-center gap-2 px-12">
<Carousel setApi={setApi} aria-label="Numbers" className="w-full">
<CarouselContent>
{slides.map((value) => (
<CarouselItem key={value}>
<div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
{value}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
<p className="text-sm text-muted-foreground">
Slide {current} of {count}
</p>
</div>
)
}Several per view#
Items set their own basis. Gutters come from the spacing prop, so they stay exact at any basis.
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel"
export function CarouselSeveralPerView() {
return (
<div className="w-full max-w-md px-12">
<Carousel spacing="sm" aria-label="Several per view">
<CarouselContent>
{Array.from({ length: 9 }, (_, index) => (
<CarouselItem key={index} className="basis-1/2 sm:basis-1/3">
<div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-3xl font-semibold">
{index + 1}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
)
}Dots and counter#
The active dot stretches as the slides scroll and its neighbours make room. The counter only spins the digit that changed.
import {
Carousel,
CarouselContent,
CarouselCounter,
CarouselDots,
CarouselItem,
} from "@/components/ui/carousel"
export function CarouselDotsAndCounter() {
return (
<Carousel aria-label="Dots and counter" className="w-full max-w-md">
<CarouselContent>
{Array.from({ length: 12 }, (_, index) => (
<CarouselItem key={index} className="basis-4/5">
<div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
{index + 1}
</div>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-3 flex items-center justify-between gap-4">
<CarouselCounter />
<CarouselDots />
</div>
</Carousel>
)
}Autoplay#
autoplay is off by default and stays off with reduced motion. It pauses on hover, keyboard focus, touch, drag, a hidden tab, or when scrolled out of view, and the active dot fills as the timer runs.
import {
Carousel,
CarouselAutoplayToggle,
CarouselContent,
CarouselDots,
CarouselItem,
} from "@/components/ui/carousel"
const slides = [1, 2, 3, 4, 5]
export function CarouselAutoplay() {
return (
<Carousel
autoplay={{ delay: 3000 }}
aria-label="Autoplay"
className="w-full max-w-xs"
>
<CarouselContent>
{slides.map((value) => (
<CarouselItem key={value}>
<div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
{value}
</div>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-3 flex items-center justify-center gap-2">
<CarouselAutoplayToggle />
<CarouselDots />
</div>
</Carousel>
)
}Thumbnails#
<CarouselThumbnails /> follows the main carousel and scrolls to keep the active thumbnail in view.
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselThumbnail,
CarouselThumbnails,
} from "@/components/ui/carousel"
const photos = Array.from({ length: 10 }, (_, index) => index + 1)
export function CarouselWithThumbnails() {
return (
<Carousel aria-label="Gallery" className="w-full max-w-md">
<CarouselContent>
{photos.map((photo) => (
<CarouselItem key={photo}>
<div className="relative overflow-hidden rounded-xl border">
<img
src="/preview/landscape.svg"
alt={`Landscape ${photo}`}
draggable={false}
className="aspect-video w-full object-cover"
/>
<span className="absolute start-3 top-3 rounded-md bg-background px-2 py-1 text-xs font-medium">
{photo}
</span>
</div>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-3">
<CarouselThumbnails>
{photos.map((photo) => (
<CarouselThumbnail key={photo} className="w-20">
<img
src="/preview/landscape.svg"
alt=""
draggable={false}
className="aspect-video w-full object-cover"
/>
</CarouselThumbnail>
))}
</CarouselThumbnails>
</div>
</Carousel>
)
}Vertical#
orientation="vertical" needs a height on <CarouselContent />. The buttons move above and below.
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel"
const slides = [1, 2, 3, 4, 5]
export function CarouselVertical() {
return (
<div className="w-full max-w-xs py-12">
<Carousel orientation="vertical" aria-label="Vertical">
<CarouselContent className="h-52">
{slides.map((value) => (
<CarouselItem key={value} className="basis-1/2">
<div className="flex h-full items-center justify-center rounded-xl border bg-card p-6 text-3xl font-semibold">
{value}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
)
}Controlled#
Pass index and onIndexChange. Swiping updates your state and your state scrolls the carousel.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
} from "@/components/ui/carousel"
const slides = [1, 2, 3, 4, 5]
export function CarouselControlled() {
const [index, setIndex] = React.useState(2)
return (
<div className="flex w-full max-w-xs flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
{slides.map((value, position) => (
<Button
key={value}
size="sm"
variant={position === index ? "secondary" : "outline"}
onClick={() => setIndex(position)}
>
{value}
</Button>
))}
<span className="text-sm text-muted-foreground">index = {index}</span>
</div>
<Carousel index={index} onIndexChange={setIndex} aria-label="Controlled">
<CarouselContent>
{slides.map((value) => (
<CarouselItem key={value}>
<div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
{value}
</div>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-3 flex justify-center">
<CarouselDots />
</div>
</Carousel>
</div>
)
}Rewind and start index#
rewind sends Next on the last slide back to the first. defaultIndex opens on a slide without a scroll animation.
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel"
const slides = [1, 2, 3, 4, 5]
export function CarouselRewind() {
return (
<div className="w-full max-w-xs px-12">
<Carousel rewind defaultIndex={2} aria-label="Rewind">
<CarouselContent>
{slides.map((value) => (
<CarouselItem key={value}>
<div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
{value}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
)
}Links and focusable content#
Dragging a link with the mouse scrolls without opening it. Tabbing to a slide that is off screen scrolls it into view.
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
} from "@/components/ui/carousel"
const articles = Array.from({ length: 6 }, (_, index) => index + 1)
export function CarouselLinks() {
return (
<Carousel spacing="sm" aria-label="Articles" className="w-full max-w-md">
<CarouselContent>
{articles.map((article) => (
<CarouselItem key={article} className="basis-4/5 sm:basis-1/2">
<a
href="#"
className="flex h-32 flex-col justify-end gap-1 rounded-xl border bg-card p-4 outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden"
>
<span className="text-sm font-medium">Article {article}</span>
<span className="text-sm text-muted-foreground">
Read the full story
</span>
</a>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-3 flex justify-center">
<CarouselDots />
</div>
</Carousel>
)
}Adding and removing slides#
Dots, the counter and the buttons update as slides come and go.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Carousel,
CarouselContent,
CarouselCounter,
CarouselDots,
CarouselItem,
} from "@/components/ui/carousel"
export function CarouselDynamic() {
const [slides, setSlides] = React.useState([1, 2, 3])
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setSlides([...slides, slides.length + 1])}
>
Add slide
</Button>
<Button
size="sm"
variant="outline"
disabled={slides.length === 0}
onClick={() => setSlides(slides.slice(0, -1))}
>
Remove slide
</Button>
</div>
<Carousel aria-label="Dynamic">
<CarouselContent>
{slides.map((value) => (
<CarouselItem key={value} className="basis-1/2">
<div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
{value}
</div>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-3 flex items-center justify-between">
<CarouselCounter />
<CarouselDots />
</div>
</Carousel>
</div>
)
}Nested#
Arrow keys, dragging and dots only move the carousel you are in.
Outer slide 1
Outer slide 2
Outer slide 3
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
} from "@/components/ui/carousel"
const outerSlides = [1, 2, 3]
const innerSlides = [1, 2, 3, 4, 5]
export function CarouselNested() {
return (
<Carousel aria-label="Outer" className="w-full max-w-md">
<CarouselContent>
{outerSlides.map((outer) => (
<CarouselItem key={outer}>
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4">
<p className="text-sm font-medium">Outer slide {outer}</p>
<Carousel spacing="sm" aria-label={`Inner ${outer}`}>
<CarouselContent>
{innerSlides.map((inner) => (
<CarouselItem key={inner} className="basis-1/3">
<div className="flex aspect-square items-center justify-center rounded-lg bg-muted text-lg font-medium">
{outer}.{inner}
</div>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-2 flex justify-center">
<CarouselDots />
</div>
</Carousel>
</div>
</CarouselItem>
))}
</CarouselContent>
<div className="mt-3 flex justify-center">
<CarouselDots />
</div>
</Carousel>
)
}Long content and a single slide#
Unbroken text wraps inside its slide. With one slide the dots hide and the buttons stay disabled.
import {
Carousel,
CarouselContent,
CarouselCounter,
CarouselDots,
CarouselItem,
} from "@/components/ui/carousel"
export function CarouselLongContent() {
return (
<div className="flex w-full max-w-md flex-col gap-6">
<Carousel aria-label="Long content">
<CarouselContent>
<CarouselItem className="basis-4/5">
<div className="rounded-xl border bg-card p-4 text-sm wrap-anywhere">
Supercalifragilisticexpialidocious_with_an_unbroken_string_that_never_ends_and_keeps_going_well_past_the_edge
</div>
</CarouselItem>
<CarouselItem className="basis-4/5">
<div className="rounded-xl border bg-card p-4 text-sm">
مرحبا 你好 👩👩👧👦 A second slide with mixed scripts.
</div>
</CarouselItem>
</CarouselContent>
<div className="mt-3 flex justify-center">
<CarouselDots />
</div>
</Carousel>
<Carousel aria-label="Single slide">
<CarouselContent>
<CarouselItem>
<div className="flex aspect-video items-center justify-center rounded-xl border bg-card p-6 text-2xl font-semibold">
Only one
</div>
</CarouselItem>
</CarouselContent>
<div className="mt-3 flex items-center justify-between">
<CarouselCounter />
<CarouselDots />
</div>
</Carousel>
</div>
)
}Right to left#
Slides start on the right, the arrows flip, the Left arrow key moves forward and the dots fill from the right.
import {
Carousel,
CarouselContent,
CarouselCounter,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel"
const slides = [1, 2, 3, 4, 5]
export function CarouselRtl() {
return (
<div dir="rtl" className="w-full max-w-md px-12">
<Carousel autoplay={{ delay: 4000 }} aria-label="شرائح">
<CarouselContent>
{slides.map((value) => (
<CarouselItem key={value} className="basis-1/2">
<div className="flex aspect-square items-center justify-center rounded-xl border bg-card p-6 text-4xl font-semibold">
{value}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<div className="mt-3 flex items-center justify-between">
<CarouselCounter />
<CarouselDots />
</div>
</Carousel>
</div>
)
}Keys work while focus is anywhere inside the carousel, except in text fields and nested carousels.
| Key | Action |
|---|---|
| → | Next slide. Previous in right-to-left layouts. ↓ in vertical carousels. |
| ← | Previous slide. Next in right-to-left layouts. ↑ in vertical carousels. |
| Tab | Moves through the buttons, the active dot and the slides’ content, scrolling off-screen slides into view. |
| EnterSpace | Activates the focused button, dot or thumbnail. |
- The root is a
regiondescribed as a carousel. Give it anaria-label. - Each item is a
groupdescribed as a slide and labelled with its position, like “3 of 5”. - A polite live region announces the new slide after keyboard or button navigation, and stays quiet while autoplay runs.
- Dots and thumbnails use a single tab stop, and focus follows the active one.
- Previous and Next stay focusable when disabled, so focus is never lost at either end.
| Prop | Type | Default |
|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" |
spacingGap between slides. | "none" | "sm" | "default" | "lg" | "default" |
index | number | – |
defaultIndex | number | 0 |
onIndexChange | (index: number) => void | – |
rewindWrap from the last slide back to the first. | boolean | false |
mouseDragLet a mouse drag the slides. | boolean | true |
autoplayAdvance on a timer. delay defaults to 5000ms, minimum 1000ms. | boolean | { delay?: number } | false |
setApi | (api: CarouselApi) => void | – |
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="carousel" | Target the root in CSS. |
data-orientation | The orientation. |
--carousel-spacing | The gap between slides, set by spacing. |
| Prop | Type | Default |
|---|---|---|
classNameApplied to the track that holds the slides. | string | – |
viewportClassNameApplied to the scrolling viewport. | string | – |
| Attribute | Description |
|---|---|
data-slot="carousel-content" | The scrolling viewport. |
data-slot="carousel-container" | The track inside it. |
data-scrollable | Present when there is more than one position. |
data-dragging | Present while a mouse drag is in progress. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <div> |
| Attribute | Description |
|---|---|
data-slot="carousel-item" | Set basis-* to show several per view. |
Both render a <Button /> and accept its props. They sit outside the content, so leave room around the carousel.
| Prop | Type | Default |
|---|---|---|
variant | ButtonProps["variant"] | "outline" |
size | ButtonProps["size"] | "icon-sm" |
childrenFollows the orientation and direction. | ReactNode | Arrow icon |
| Attribute | Description |
|---|---|
data-slot="carousel-previous" | Labelled “Previous slide”. |
data-slot="carousel-next" | Labelled “Next slide”. |
data-disabled | Present at either end. The button stays focusable. |
| Prop | Type | Default |
|---|---|---|
aria-label | string | "Choose slide" |
| Attribute | Description |
|---|---|
data-slot="carousel-dots" | The group of dots. Hidden with one position. |
data-slot="carousel-dot" | Each dot. The active one has aria-current. |
--dot-active | 0 to 1, how active a dot is while scrolling. |
| Attribute | Description |
|---|---|
data-slot="carousel-counter" | Shows the current position over the total with a rolling digit. |
| Prop | Type | Default |
|---|---|---|
variant | ButtonProps["variant"] | "ghost" |
size | ButtonProps["size"] | "icon-sm" |
| Attribute | Description |
|---|---|
data-slot="carousel-autoplay-toggle" | Labelled “Pause slideshow” or “Play slideshow”. |
| Prop | Type | Default |
|---|---|---|
aria-label | string | "Slides" |
| Attribute | Description |
|---|---|
data-slot="carousel-thumbnails" | The scrolling strip. |
| Prop | Type | Default |
|---|---|---|
indexThe slide it opens. Defaults to its position in the strip. | number | – |
render | ReactElement | (props, state) => ReactElement | <button> |
| Attribute | Description |
|---|---|
data-slot="carousel-thumbnail" | Target thumbnails in CSS. |
data-active | Present while its slide is in view. |
Returned through setApi and useCarousel(). Pass jump: true to move without animating.
| Prop | Type | Default |
|---|---|---|
scrollPrev | (jump?: boolean) => void | – |
scrollNext | (jump?: boolean) => void | – |
scrollToScrolls to a snap position. | (index: number, jump?: boolean) => void | – |
scrollToSlideScrolls to the position that shows a slide. | (slideIndex: number, jump?: boolean) => void | – |
canScrollPrev | () => boolean | – |
canScrollNext | () => boolean | – |
selectedScrollSnap | () => number | – |
scrollSnapList | () => number[] | – |
slidesInView | () => number[] | – |
slideNodes | () => HTMLElement[] | – |
viewportNode | () => HTMLElement | null | – |
play | () => void | – |
stop | () => void | – |
isPlaying | () => boolean | – |
on / off | (event: "select" | "scroll" | "settle" | "reInit", listener) => CarouselApi | – |
Use inside <Carousel /> to build your own controls. Returns api, orientation, selectedIndex, snapCount, slideCount, slidesInView, canScrollPrev, canScrollNext, isPlaying and the scroll and play methods.