# Carousel

> Native scroll-snap slides with momentum on touch, mouse dragging, arrow keys, dots, thumbnails and an autoplay that pauses when it should.

Docs: https://hextaui.com/docs/carousel
Markdown: https://hextaui.com/docs/carousel.md

```tsx title="components/examples/carousel/demo.tsx"
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>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/carousel.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="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,
}
```

```tsx title="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 }
```

```tsx title="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,
}
```

```ts title="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 }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  Carousel,
  CarouselContent,
  CarouselDots,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"
```

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

## Composition

```text
Carousel
├── CarouselContent
│   └── CarouselItem
├── CarouselPrevious
├── CarouselNext
├── CarouselDots
├── CarouselCounter
├── CarouselAutoplayToggle
└── CarouselThumbnails
    └── CarouselThumbnail
```

## Examples

### API

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.

```tsx title="components/examples/carousel/api.tsx"
"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.

```tsx title="components/examples/carousel/several-per-view.tsx"
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.

```tsx title="components/examples/carousel/dots-and-counter.tsx"
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.

```tsx title="components/examples/carousel/autoplay.tsx"
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.

```tsx title="components/examples/carousel/thumbnails.tsx"
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.

```tsx title="components/examples/carousel/vertical.tsx"
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.

```tsx title="components/examples/carousel/controlled.tsx"
"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.

```tsx title="components/examples/carousel/rewind.tsx"
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.

```tsx title="components/examples/carousel/links.tsx"
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.

```tsx title="components/examples/carousel/dynamic.tsx"
"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.

```tsx title="components/examples/carousel/nested.tsx"
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.

```tsx title="components/examples/carousel/long-content.tsx"
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.

```tsx title="components/examples/carousel/rtl.tsx"
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>
  )
}
```

## Keyboard

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. |
| `Enter` `Space` | Activates the focused button, dot or thumbnail. |

## Accessibility

- The root is a `region` described as a carousel. Give it an `aria-label`.
- Each item is a `group` described 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.

## API reference

### Carousel

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` |  |
| `spacing` | `"none" \| "sm" \| "default" \| "lg"` | `"default"` | Gap between slides. |
| `index` | `number` | – |  |
| `defaultIndex` | `number` | `0` |  |
| `onIndexChange` | `(index: number) => void` | – |  |
| `rewind` | `boolean` | `false` | Wrap from the last slide back to the first. |
| `mouseDrag` | `boolean` | `true` | Let a mouse drag the slides. |
| `autoplay` | `boolean \| { delay?: number }` | `false` | Advance on a timer. delay defaults to 5000ms, minimum 1000ms. |
| `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. |

### CarouselContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | – | Applied to the track that holds the slides. |
| `viewportClassName` | `string` | – | Applied to the scrolling viewport. |

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

### CarouselItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="carousel-item"` | Set basis-* to show several per view. |

### CarouselPrevious and CarouselNext

Both render a `<Button />` and accept its props. They sit outside the content, so leave room around the carousel.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `ButtonProps["variant"]` | `"outline"` |  |
| `size` | `ButtonProps["size"]` | `"icon-sm"` |  |
| `children` | `ReactNode` | `Arrow icon` | Follows the orientation and direction. |

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

### CarouselDots

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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. |

### CarouselCounter

| Attribute | Description |
| --- | --- |
| `data-slot="carousel-counter"` | Shows the current position over the total with a rolling digit. |

### CarouselAutoplayToggle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `ButtonProps["variant"]` | `"ghost"` |  |
| `size` | `ButtonProps["size"]` | `"icon-sm"` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="carousel-autoplay-toggle"` | Labelled “Pause slideshow” or “Play slideshow”. |

### CarouselThumbnails

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label` | `string` | `"Slides"` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="carousel-thumbnails"` | The scrolling strip. |

### CarouselThumbnail

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `index` | `number` | – | The slide it opens. Defaults to its position in the strip. |
| `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. |

### CarouselApi

Returned through `setApi` and `useCarousel()`. Pass `jump: true` to move without animating.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `scrollPrev` | `(jump?: boolean) => void` | – |  |
| `scrollNext` | `(jump?: boolean) => void` | – |  |
| `scrollTo` | `(index: number, jump?: boolean) => void` | – | Scrolls to a snap position. |
| `scrollToSlide` | `(slideIndex: number, jump?: boolean) => void` | – | Scrolls to the position that shows a slide. |
| `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` | – |  |

### useCarousel

Use inside `<Carousel />` to build your own controls. Returns `api`, `orientation`, `selectedIndex`, `snapCount`, `slideCount`, `slidesInView`, `canScrollPrev`, `canScrollNext`, `isPlaying` and the scroll and play methods.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
