New: Activity Terrain +12 more

Carousel

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

14 examplesDepends on@base-ui/react@tabler/icons-reactclass-variance-authoritycnFrom HextaUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/carousel.json
Install manually
  1. Install the dependencies:

    npm install @base-ui/react @tabler/icons-react class-variance-authority cn
  2. It builds on these, so copy them first:

  3. Copy this into components/ui/carousel.tsx:

    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,
    }

Examples

API

Autoplay

Controlled

Dots and counter

Adding and removing slides

Long content and a single slide

Nested

Rewind and start index

Right to left

Several per view

Thumbnails

Vertical

Feedback

Questions, bugs or ideas for Carousel? Post them here, or reply to someone else. Sign in with GitHub to comment.