# Carousel

> Accessible slide carousel with prev/next, dots, drag and swipe, keyboard arrows, loop and pausable autoplay.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/carousel
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/carousel.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/carousel-demo.tsx"
import { Carousel, type CarouselSlide } from "@/components/carousel"

function Card({ eyebrow, title, body, gradient }: { eyebrow: string; title: string; body: string; gradient: string }) {
  return (
    <div className={`flex h-56 flex-col justify-end gap-2 p-6 text-white sm:h-60 sm:p-8 ${gradient}`}>
      <p className="text-xs font-semibold tracking-widest text-white/75 uppercase">{eyebrow}</p>
      <h3 className="text-2xl leading-tight font-semibold text-balance sm:text-3xl">{title}</h3>
      <p className="max-w-md text-sm text-white/85">{body}</p>
    </div>
  )
}

const slides: CarouselSlide[] = [
  {
    id: "design",
    label: "Design tokens",
    content: (
      <Card
        eyebrow="Design"
        title="Tokens that follow your brand"
        body="Colors, radii and spacing flow from one theme file into every component you copy."
        gradient="bg-linear-to-br from-violet-600 via-indigo-600 to-sky-500"
      />
    ),
  },
  {
    id: "a11y",
    label: "Accessibility",
    content: (
      <Card
        eyebrow="Accessibility"
        title="Keyboard first, screen reader ready"
        body="Every interactive piece ships with roles, labels and visible focus states out of the box."
        gradient="bg-linear-to-br from-emerald-600 via-teal-600 to-sky-600"
      />
    ),
  },
  {
    id: "motion",
    label: "Motion",
    content: (
      <Card
        eyebrow="Motion"
        title="Animation that respects the user"
        body="Transitions are tuned to feel quick, and they switch off when reduced motion is requested."
        gradient="bg-linear-to-br from-amber-500 via-orange-500 to-rose-500"
      />
    ),
  },
  {
    id: "ship",
    label: "Shipping",
    content: (
      <Card
        eyebrow="Ship it"
        title="Copy, paste, own the code"
        body="No runtime dependency to upgrade. The source lives in your repo and you can change anything."
        gradient="bg-linear-to-br from-rose-600 via-fuchsia-600 to-violet-600"
      />
    ),
  },
]

const compact: CarouselSlide[] = [
  { id: "a", label: "Q1", content: <div className="grid h-28 place-items-center bg-linear-to-r from-sky-500 to-cyan-400 text-xl font-semibold text-white">Q1 · Foundations</div> },
  { id: "b", label: "Q2", content: <div className="grid h-28 place-items-center bg-linear-to-r from-violet-500 to-fuchsia-400 text-xl font-semibold text-white">Q2 · Growth</div> },
  { id: "c", label: "Q3", content: <div className="grid h-28 place-items-center bg-linear-to-r from-amber-500 to-rose-400 text-xl font-semibold text-white">Q3 · Scale</div> },
]

export default function CarouselDemo() {
  return (
    <div className="grid w-full max-w-xl min-w-0 grid-cols-[minmax(0,1fr)] gap-8">
      <Carousel slides={slides} loop autoplay interval={5000} label="Product highlights" />
      <Carousel slides={compact} label="Roadmap quarters" />
    </div>
  )
}
```

## Source

```tsx title="components/carousel.tsx"
"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react"

import { cn } from "@/lib/utils"

export interface CarouselSlide {
  id: string
  /** Short name used in the slide's accessible label */
  label?: string
  content: React.ReactNode
}

export interface CarouselProps extends Omit<React.ComponentProps<"section">, "children"> {
  slides: CarouselSlide[]
  /** Wrap from the last slide to the first (and back) */
  loop?: boolean
  autoplay?: boolean
  /** Autoplay delay in ms */
  interval?: number
  showDots?: boolean
  showArrows?: boolean
  /** Accessible name of the carousel */
  label?: string
}

const REDUCED_QUERY = "(prefers-reduced-motion: reduce)"

function subscribeReduced(onChange: () => void) {
  const mq = window.matchMedia(REDUCED_QUERY)
  mq.addEventListener("change", onChange)
  return () => mq.removeEventListener("change", onChange)
}

function usePrefersReducedMotion() {
  return React.useSyncExternalStore(
    subscribeReduced,
    () => window.matchMedia(REDUCED_QUERY).matches,
    () => false
  )
}

const navButton =
  "inline-flex size-9 shrink-0 items-center justify-center rounded-full border bg-background text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-40"

export function Carousel({
  slides,
  loop = false,
  autoplay = false,
  interval = 4500,
  showDots = true,
  showArrows = true,
  label = "Carousel",
  className,
  onKeyDown,
  ...props
}: CarouselProps) {
  const count = slides.length
  const reduced = usePrefersReducedMotion()
  const [index, setIndex] = React.useState(0)
  const [userPlaying, setUserPlaying] = React.useState(true)
  const [hovered, setHovered] = React.useState(false)
  const [focused, setFocused] = React.useState(false)
  const [dragging, setDragging] = React.useState(false)
  const [dragX, setDragX] = React.useState(0)
  const drag = React.useRef({ active: false, captured: false, startX: 0, width: 0 })

  const running = autoplay && userPlaying && !reduced && !hovered && !focused && !dragging && count > 1

  const go = React.useCallback(
    (target: number) => {
      if (count === 0) return
      setIndex(loop ? ((target % count) + count) % count : Math.min(Math.max(target, 0), count - 1))
    },
    [count, loop]
  )

  React.useEffect(() => {
    if (!running) return
    const timer = setTimeout(() => {
      setIndex((i) => (i + 1 < count ? i + 1 : loop ? 0 : i))
    }, interval)
    return () => clearTimeout(timer)
  }, [running, index, interval, count, loop])

  const current = Math.min(index, Math.max(count - 1, 0))
  const atStart = !loop && current === 0
  const atEnd = !loop && current === count - 1

  function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
    if (e.pointerType === "mouse" && e.button !== 0) return
    drag.current = { active: true, captured: false, startX: e.clientX, width: e.currentTarget.offsetWidth, }
  }

  function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
    const d = drag.current
    if (!d.active) return
    let dx = e.clientX - d.startX
    if (!d.captured) {
      if (Math.abs(dx) < 6) return
      d.captured = true
      e.currentTarget.setPointerCapture(e.pointerId)
      setDragging(true)
    }
    if ((current === 0 && dx > 0 && !loop) || (current === count - 1 && dx < 0 && !loop)) dx *= 0.3
    setDragX(dx)
  }

  function endDrag(e: React.PointerEvent<HTMLDivElement>) {
    const d = drag.current
    if (!d.active) return
    drag.current = { ...d, active: false, captured: false }
    if (d.captured) {
      const dx = e.clientX - d.startX
      const threshold = Math.max(48, d.width * 0.18)
      if (e.type !== "pointercancel") {
        if (dx <= -threshold) go(current + 1)
        else if (dx >= threshold) go(current - 1)
      }
      setDragging(false)
      setDragX(0)
    }
  }

  function handleKeyDown(e: React.KeyboardEvent<HTMLElement>) {
    onKeyDown?.(e)
    if (e.defaultPrevented) return
    if (e.key === "ArrowRight") {
      e.preventDefault()
      go(current + 1)
    } else if (e.key === "ArrowLeft") {
      e.preventDefault()
      go(current - 1)
    } else if (e.key === "Home") {
      e.preventDefault()
      go(0)
    } else if (e.key === "End") {
      e.preventDefault()
      go(count - 1)
    }
  }

  return (
    <section
      aria-roledescription="carousel"
      aria-label={label}
      className={cn("w-full min-w-0", className)}
      onKeyDown={handleKeyDown}
      onPointerEnter={() => setHovered(true)}
      onPointerLeave={() => setHovered(false)}
      onFocus={() => setFocused(true)}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false)
      }}
      {...props}
    >
      <div
        tabIndex={0}
        aria-label={`${label} slides. Use left and right arrow keys to change slide.`}
        className={cn(
          "overflow-hidden rounded-2xl outline-none select-none focus-visible:ring-4 focus-visible:ring-ring/40",
          count > 1 && "cursor-grab active:cursor-grabbing"
        )}
        style={{ touchAction: "pan-y" }}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
      >
        <div
          className={cn("flex", !dragging && "transition-transform duration-500 ease-out motion-reduce:transition-none")}
          style={{ transform: `translate3d(calc(${-current * 100}% + ${dragX}px), 0, 0)` }}
        >
          {slides.map((slide, i) => (
            <div
              key={slide.id}
              role="group"
              aria-roledescription="slide"
              aria-label={`${i + 1} of ${count}${slide.label ? `: ${slide.label}` : ""}`}
              aria-hidden={i !== current}
              inert={i !== current}
              className="w-full min-w-0 shrink-0 basis-full"
            >
              {slide.content}
            </div>
          ))}
        </div>
      </div>
      {(showArrows || showDots) && count > 1 && (
        <div className="mt-3 flex items-center justify-between gap-3">
          {showArrows ? (
            <button type="button" aria-label="Previous slide" disabled={atStart} onClick={() => go(current - 1)} className={navButton}>
              <ChevronLeft className="size-4" aria-hidden />
            </button>
          ) : (
            <span />
          )}
          <div className="flex min-w-0 items-center justify-center gap-1">
            {showDots &&
              slides.map((slide, i) => (
                <button
                  key={slide.id}
                  type="button"
                  aria-label={`Go to slide ${i + 1}`}
                  aria-current={i === current ? "true" : undefined}
                  onClick={() => go(i)}
                  className="group grid size-6 place-items-center rounded-full focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40"
                >
                  <span
                    className={cn(
                      "block h-2 rounded-full transition-all duration-300",
                      i === current ? "w-5 bg-primary" : "w-2 bg-muted-foreground/30 group-hover:bg-muted-foreground/60"
                    )}
                  />
                </button>
              ))}
            {autoplay && !reduced && (
              <button
                type="button"
                aria-label={userPlaying ? "Pause autoplay" : "Start autoplay"}
                onClick={() => setUserPlaying((p) => !p)}
                className="ml-1 inline-flex size-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40"
              >
                {userPlaying ? <Pause className="size-3.5" aria-hidden /> : <Play className="size-3.5" aria-hidden />}
              </button>
            )}
          </div>
          {showArrows ? (
            <button type="button" aria-label="Next slide" disabled={atEnd} onClick={() => go(current + 1)} className={navButton}>
              <ChevronRight className="size-4" aria-hidden />
            </button>
          ) : (
            <span />
          )}
        </div>
      )}
      <p className="sr-only" aria-live={running ? "off" : "polite"}>
        Slide {current + 1} of {count}
      </p>
    </section>
  )
}
```
