{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "carousel",
  "title": "Carousel",
  "description": "A scroll-snap carousel with native momentum, mouse drag and arrow keys. Its dot stretches between pages as you scroll and its edges fade only where there is more.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-gesture.json",
    "https://eased.dev/r/eased-scroll-fade.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/carousel/carousel.tsx",
      "content": "\"use client\";\n\nimport { motion, useMotionValue, useTransform, type MotionValue } from \"motion/react\";\nimport {\n  Children,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { createVelocityTracker, nearest, project, DECELERATION } from \"../_primitives/gesture/gesture-math\";\nimport { scrollFadeMask, useScrollEdges } from \"../_primitives/scroll-fade/scroll-fade\";\nimport { useSafeReducedMotion } from \"../lib/motion\";\nimport { pressIcon } from \"../lib/press\";\n\nexport type CarouselProps = {\n  /** One child per slide. */\n  children: ReactNode;\n  /** Names the carousel, e.g. \"Featured plans\". */\n  \"aria-label\": string;\n  /** Slide width as any CSS length. Default `\"80%\"`, so the next slide peeks in. */\n  slideSize?: string;\n  /** Gap between slides, px. Default 12. */\n  gap?: number;\n  /** Where a slide comes to rest. Default `\"start\"`. */\n  align?: \"start\" | \"center\";\n  /** The page indicator under the slides: dots, a progress bar, or none. Default `\"dots\"`. */\n  indicator?: \"dots\" | \"bar\" | \"none\";\n  /** Show the previous/next buttons. Default true. */\n  controls?: boolean;\n  /** Next on the last page goes back to the first (and Previous on the first to the last). Default false. */\n  loop?: boolean;\n  /** Drag with a mouse to scroll, with the flick's momentum carried to a snap point. Touch and trackpads always scroll natively. Default true. */\n  draggable?: boolean;\n  /** The current page (controlled). */\n  index?: number;\n  /** The starting page (uncontrolled). Default 0. */\n  defaultIndex?: number;\n  /** Called when the page nearest the resting point changes, while scrolling too. */\n  onIndexChange?: (index: number) => void;\n  /** Something before the controls in the footer row, e.g. a caption. Replaces the indicator's slot on the left. */\n  footer?: ReactNode;\n  /** Classes for the root. */\n  className?: string;\n  /** Classes for each slide wrapper. */\n  slideClassName?: string;\n};\n\n/**\n * A scroll-snap carousel: native scrolling with the platform's momentum, prev/next buttons,\n * a page indicator that follows the scroll position continuously (not page by page), arrow\n * keys, mouse drag with flick momentum, and edges that feather only where there is more.\n */\nexport function Carousel({\n  children,\n  \"aria-label\": ariaLabel,\n  slideSize = \"80%\",\n  gap = 12,\n  align = \"start\",\n  indicator = \"dots\",\n  controls = true,\n  loop = false,\n  draggable = true,\n  index: indexProp,\n  defaultIndex = 0,\n  onIndexChange,\n  footer,\n  className = \"\",\n  slideClassName = \"\",\n}: CarouselProps) {\n  const slides = Children.toArray(children);\n  const reduced = useSafeReducedMotion();\n  const scroller = useRef<HTMLDivElement>(null);\n  const slideRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const stops = useRef<number[]>([0]);\n  const [pages, setPages] = useState(1);\n  const [index, setIndex] = useState(indexProp ?? defaultIndex);\n  const indexRef = useRef(index);\n  const pending = useRef<number | null>(null);\n  const position = useMotionValue(indexProp ?? defaultIndex);\n  const [dragging, setDragging] = useState(false);\n  const id = useId();\n  const latest = useRef({ onIndexChange, reduced });\n  useLayoutEffect(() => {\n    latest.current = { onIndexChange, reduced };\n  });\n  useScrollEdges(scroller, \"x\");\n\n  /** The fractional page under the scroll position, interpolated between snap stops. */\n  const measure = useCallback(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const s = stops.current;\n    const x = Math.abs(el.scrollLeft);\n    let p = 0;\n    if (x >= s[s.length - 1]) p = s.length - 1;\n    else\n      for (let i = 0; i < s.length - 1; i++) {\n        if (x < s[i + 1]) {\n          p = i + (x - s[i]) / Math.max(1, s[i + 1] - s[i]);\n          break;\n        }\n      }\n    position.set(p);\n    const rounded = Math.round(p);\n    if (pending.current === rounded) pending.current = null;\n    if (rounded !== indexRef.current) {\n      indexRef.current = rounded;\n      setIndex(rounded);\n      latest.current.onIndexChange?.(rounded);\n    }\n  }, [position]);\n\n  /** Snap stops: each slide's resting scroll position, clamped to the end, duplicates dropped. */\n  const computeStops = useCallback(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const max = el.scrollWidth - el.clientWidth;\n    const pad = parseFloat(getComputedStyle(el).scrollPaddingInlineStart) || 0;\n    const out: number[] = [];\n    for (const slide of slideRefs.current) {\n      if (!slide) continue;\n      const raw = align === \"center\" ? slide.offsetLeft - (el.clientWidth - slide.offsetWidth) / 2 : slide.offsetLeft - pad;\n      const stop = Math.round(Math.min(max, Math.max(0, raw)));\n      if (!out.length || stop - out[out.length - 1] > 1) out.push(stop);\n    }\n    stops.current = out.length ? out : [0];\n    setPages(stops.current.length);\n    measure();\n  }, [align, measure]);\n\n  useLayoutEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    computeStops();\n    // Land on the starting page without animating.\n    const start = stops.current[Math.min(indexRef.current, stops.current.length - 1)] ?? 0;\n    el.scrollLeft = start;\n    measure();\n    const ro = new ResizeObserver(computeStops);\n    ro.observe(el);\n    for (const slide of slideRefs.current) if (slide) ro.observe(slide);\n    el.addEventListener(\"scroll\", measure, { passive: true });\n    return () => {\n      ro.disconnect();\n      el.removeEventListener(\"scroll\", measure);\n    };\n  }, [computeStops, measure, slides.length]);\n\n  const goTo = useCallback((page: number, smooth = true) => {\n    const el = scroller.current;\n    if (!el) return;\n    const s = stops.current;\n    pending.current = Math.min(s.length - 1, Math.max(0, page));\n    const target = s[pending.current];\n    el.scrollTo({ left: target, behavior: smooth && !latest.current.reduced ? \"smooth\" : \"auto\" });\n  }, []);\n\n  // Controlled index: scroll to it when it changes from outside.\n  useEffect(() => {\n    if (indexProp === undefined || indexProp === indexRef.current) return;\n    goTo(indexProp);\n  }, [indexProp, goTo]);\n\n  const last = pages - 1;\n  // Step from the page a scroll is heading to, so a second press before it lands still counts.\n  const prev = () => {\n    const at = pending.current ?? index;\n    goTo(at <= 0 ? (loop ? last : 0) : at - 1);\n  };\n  const next = () => {\n    const at = pending.current ?? index;\n    goTo(at >= last ? (loop ? 0 : last) : at + 1);\n  };\n\n  const onKeyDown = (e: ReactKeyboardEvent) => {\n    if (e.target !== e.currentTarget) return;\n    if (e.key === \"ArrowRight\") next();\n    else if (e.key === \"ArrowLeft\") prev();\n    else if (e.key === \"Home\") goTo(0);\n    else if (e.key === \"End\") goTo(last);\n    else return;\n    e.preventDefault();\n  };\n\n  // Mouse drag: scroll follows the pointer with snapping off, then the flick is projected\n  // (UIScrollView friction) onto the snap stop it would coast to and scrolled there.\n  const drag = useRef<{ x: number; left: number; moved: boolean; pointer: number } | null>(null);\n  const tracker = useRef(createVelocityTracker());\n  const suppressClick = useRef(false);\n  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {\n    if (!draggable || e.pointerType !== \"mouse\" || e.button !== 0) return;\n    pending.current = null;\n    const el = scroller.current!;\n    drag.current = { x: e.clientX, left: el.scrollLeft, moved: false, pointer: e.pointerId };\n    tracker.current.reset();\n    tracker.current.add(el.scrollLeft);\n  };\n  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    const el = scroller.current;\n    if (!d || !el || e.pointerId !== d.pointer) return;\n    const dx = e.clientX - d.x;\n    if (!d.moved) {\n      if (Math.abs(dx) < 4) return;\n      d.moved = true;\n      el.setPointerCapture(e.pointerId);\n      el.style.scrollSnapType = \"none\";\n      el.style.scrollBehavior = \"auto\";\n      setDragging(true);\n    }\n    el.scrollLeft = d.left - dx;\n    tracker.current.add(el.scrollLeft);\n  };\n  const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    const el = scroller.current;\n    drag.current = null;\n    if (!d || !el || !d.moved) return;\n    if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);\n    setDragging(false);\n    suppressClick.current = true;\n    const velocity = tracker.current.velocity();\n    const target = stops.current[nearest(el.scrollLeft + project(velocity, DECELERATION.fast), stops.current)];\n    // Snapping comes back once the glide has landed, so it never fights the smooth scroll.\n    const restore = () => {\n      el.style.scrollSnapType = \"\";\n      el.style.scrollBehavior = \"\";\n    };\n    if (Math.abs(target - el.scrollLeft) < 1 || latest.current.reduced) {\n      el.scrollLeft = target;\n      restore();\n      return;\n    }\n    let done = false;\n    const finish = () => {\n      if (done) return;\n      done = true;\n      el.removeEventListener(\"scrollend\", finish);\n      restore();\n    };\n    el.addEventListener(\"scrollend\", finish);\n    window.setTimeout(finish, 900);\n    el.scrollTo({ left: target, behavior: \"smooth\" });\n  };\n\n  const snapAlign = align === \"center\" ? \"snap-center\" : \"snap-start\";\n\n  return (\n    <div role=\"region\" aria-roledescription=\"carousel\" aria-label={ariaLabel} className={`flex min-w-0 flex-col gap-3 ${className}`}>\n      <div className=\"relative flex min-h-0 min-w-0 flex-1 flex-col\">\n      <div\n        ref={scroller}\n        id={id}\n        tabIndex={0}\n        aria-label={`${ariaLabel}, use arrow keys to move between slides`}\n        onKeyDown={onKeyDown}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onClickCapture={(e) => {\n          if (!suppressClick.current) return;\n          suppressClick.current = false;\n          e.preventDefault();\n          e.stopPropagation();\n        }}\n        onDragStart={(e) => draggable && e.preventDefault()}\n        style={{ ...scrollFadeMask(\"x\", 20), gap, scrollPaddingInline: \"var(--carousel-inset, 0px)\", paddingInline: \"var(--carousel-inset, 0px)\" }}\n        className={`peer flex min-h-0 flex-1 snap-x snap-mandatory overflow-x-auto overscroll-x-contain outline-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${\n          draggable ? \"pointer-fine:cursor-grab\" : \"\"\n        } ${dragging ? \"cursor-grabbing! select-none\" : \"\"}`}\n      >\n        {slides.map((slide, i) => (\n          <div\n            key={i}\n            ref={(el) => {\n              slideRefs.current[i] = el;\n            }}\n            role=\"group\"\n            aria-roledescription=\"slide\"\n            aria-label={`${i + 1} of ${slides.length}`}\n            className={`min-w-0 shrink-0 ${snapAlign} ${slideClassName}`}\n            style={{ width: slideSize }}\n          >\n            {slide}\n          </div>\n        ))}\n      </div>\n      {/* The scroller's mask would hide a ring drawn on itself, so the focus ring is a sibling. */}\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-y-0 inset-x-(--carousel-inset,0px) rounded-control opacity-0 shadow-[0_0_0_2px_var(--eased-bg),0_0_0_4px_var(--eased-ring)] transition-opacity duration-(--eased-duration-focus) ease-standard peer-focus-visible:opacity-100\"\n      />\n      </div>\n      {(controls || indicator !== \"none\" || footer) && (\n        <div className=\"flex min-h-8 items-center justify-between gap-3 px-(--carousel-inset,0px)\">\n          <div className=\"flex min-w-0 items-center\">\n            {footer ??\n              (pages > 1 &&\n                (indicator === \"dots\" ? (\n                  <Dots pages={pages} position={position} index={index} onSelect={goTo} controls={id} />\n                ) : indicator === \"bar\" ? (\n                  <Bar pages={pages} position={position} />\n                ) : null))}\n          </div>\n          {controls && pages > 1 && (\n            <div className=\"flex shrink-0 items-center gap-1\">\n              <ArrowButton label=\"Previous slide\" dir={-1} disabled={index <= 0 && !loop} onClick={prev} controls={id} />\n              <ArrowButton label=\"Next slide\" dir={1} disabled={index >= last && !loop} onClick={next} controls={id} />\n            </div>\n          )}\n        </div>\n      )}\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {`Slide ${index + 1} of ${pages}`}\n      </p>\n    </div>\n  );\n}\n\n/* ---------- Indicators ---------------------------------------------------------- */\n\nconst DOT = 6;\nconst PITCH = 16;\n\n/**\n * Dots with one highlight that travels with the scroll: between two pages its leading edge\n * runs ahead and its trailing edge catches up, so it stretches into a short worm and settles\n * back to a dot. Drawn as a clip-path on one bar, so nothing reflows.\n */\nfunction Dots({ pages, position, index, onSelect, controls }: { pages: number; position: MotionValue<number>; index: number; onSelect: (i: number) => void; controls: string }) {\n  const width = pages * PITCH;\n  const inset = (PITCH - DOT) / 2;\n  const clip = useTransform(position, (p) => {\n    const clamped = Math.min(pages - 1, Math.max(0, p));\n    const i = Math.floor(clamped);\n    const t = clamped - i;\n    // Leading edge eases out (runs ahead), trailing edge eases in (catches up).\n    const lead = 1 - (1 - t) * (1 - t);\n    const trail = t * t;\n    const left = inset + (i + trail) * PITCH;\n    const right = width - (inset + DOT + (i + lead) * PITCH);\n    return `inset(${(24 - DOT) / 2}px ${Math.max(0, right)}px ${(24 - DOT) / 2}px ${left}px round ${DOT / 2}px)`;\n  });\n  return (\n    <div className=\"relative -ml-[5px] flex h-6\" style={{ width }}>\n      {Array.from({ length: pages }, (_, i) => (\n        <button\n          key={i}\n          type=\"button\"\n          aria-label={`Go to slide ${i + 1} of ${pages}`}\n          aria-current={i === index ? \"true\" : undefined}\n          aria-controls={controls}\n          onClick={() => onSelect(i)}\n          className=\"group flex h-6 shrink-0 cursor-pointer items-center justify-center rounded-pill focus-ring\"\n          style={{ width: PITCH }}\n        >\n          <span className=\"size-1.5 rounded-pill bg-line-strong transition-colors duration-150 ease-standard group-hover-fine:bg-text-muted\" />\n        </button>\n      ))}\n      <motion.span aria-hidden className=\"pointer-events-none absolute inset-0 bg-text\" style={{ clipPath: clip }} />\n    </div>\n  );\n}\n\n/** A thin track whose thumb is one page wide and slides with the scroll position. */\nfunction Bar({ pages, position }: { pages: number; position: MotionValue<number> }) {\n  const x = useTransform(position, (p) => `${Math.min(pages - 1, Math.max(0, p)) * 100}%`);\n  return (\n    <div aria-hidden className=\"relative h-1 w-24 overflow-clip rounded-pill bg-surface-active\">\n      <motion.span className=\"absolute inset-y-0 left-0 rounded-pill bg-text\" style={{ width: `${100 / pages}%`, x }} />\n    </div>\n  );\n}\n\nfunction ArrowButton({ label, dir, disabled, onClick, controls }: { label: string; dir: 1 | -1; disabled: boolean; onClick: () => void; controls: string }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      aria-controls={controls}\n      disabled={disabled}\n      onClick={onClick}\n      className={`inline-flex size-8 items-center justify-center rounded-pill border border-line text-text-secondary ${pressIcon} enabled:cursor-pointer enabled:hover-fine:bg-surface-hover enabled:hover-fine:text-text disabled:cursor-not-allowed disabled:text-text-disabled`}\n    >\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"size-4\">\n        {/* Chevrons sit 1px toward their direction (R57). */}\n        {dir === 1 ? <path d=\"m6.5 4 4 4-4 4\" /> : <path d=\"m9.5 4-4 4 4 4\" />}\n      </svg>\n    </button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/carousel/carousel.tsx"
    }
  ],
  "meta": {
    "industries": [
      "saas",
      "ecommerce",
      "media",
      "real-estate",
      "finance"
    ]
  },
  "docs": "Import Carousel from components/eased/carousel/carousel. Set --carousel-inset on a parent to inset the slides and footer. The Eased tokens were merged into your global CSS; nothing to import. Dark mode follows .dark or data-theme=\"dark\".",
  "type": "registry:ui"
}