{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"snap-carousel","type":"registry:ui","title":"Snap Carousel","description":"Momentum that lands on a slide.","dependencies":["motion"],"categories":["scroll"],"docs":"https://www.interior.dev/docs/snap-carousel","files":[{"path":"registry/interior/snap-carousel.tsx","type":"registry:ui","target":"components/interior/snap-carousel.tsx","content":"\"use client\";\n\nimport {\n  Children,\n  isValidElement,\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  animate,\n  motion,\n  useIsomorphicLayoutEffect,\n  useMotionValue,\n  useReducedMotion,\n} from \"motion/react\";\n\nconst CELL = {\n  type: \"spring\",\n  stiffness: 520,\n  damping: 34,\n  mass: 0.45,\n} as const;\n\nconst CROSSFADE = {\n  type: \"spring\",\n  stiffness: 260,\n  damping: 34,\n  mass: 0.8,\n} as const;\n\nconst WALL = {\n  type: \"spring\",\n  stiffness: 700,\n  damping: 30,\n  mass: 0.5,\n} as const;\n\nconst WALL_IMPULSE = 900;\n\nconst clamp = (n: number, lo: number, hi: number) =>\n  Math.max(lo, Math.min(hi, n));\n\ntype DragInfo = {\n  offset: { x: number; y: number };\n  velocity: { x: number; y: number };\n};\n\nexport type UseSnapCarouselOptions = {\n  count: number;\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  gap?: number;\n  momentum?: number;\n  maxFlick?: number;\n  disabled?: boolean;\n};\n\nexport function useSnapCarousel({\n  count,\n  index: controlled,\n  defaultIndex = 0,\n  onIndexChange,\n  gap = 12,\n  momentum = 0.14,\n  maxFlick = 1,\n  disabled = false,\n}: UseSnapCarouselOptions) {\n  const total = Math.max(1, Math.floor(count));\n\n  const [uncontrolled, setUncontrolled] = useState(() =>\n    clamp(defaultIndex, 0, total - 1),\n  );\n  const [slideWidth, setSlideWidth] = useState(0);\n  const [dragging, setDragging] = useState(false);\n\n  const index = clamp(controlled ?? uncontrolled, 0, total - 1);\n  const [target, setTarget] = useState(index);\n  const step = slideWidth + gap;\n\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const anim = useRef<{ stop: () => void } | null>(null);\n  const desired = useRef<number | null>(null);\n  const lastStep = useRef(0);\n\n  const live = useRef(index);\n  live.current = index;\n  const metrics = useRef({ step, total, momentum, maxFlick });\n  metrics.current = { step, total, momentum, maxFlick };\n  const changed = useRef(onIndexChange);\n  changed.current = onIndexChange;\n\n  const x = useMotionValue(0);\n  const reduced = useReducedMotion();\n\n  const glide = useCallback(\n    (to: number, velocity = 0) => {\n      desired.current = to;\n      anim.current?.stop();\n      anim.current = animate(\n        x,\n        to,\n        reduced ? { duration: 0 } : { ...CROSSFADE, velocity },\n      );\n    },\n    [x, reduced],\n  );\n\n  const goTo = useCallback(\n    (next: number, velocity = 0) => {\n      const shelf = metrics.current;\n      const to = clamp(Math.round(next), 0, shelf.total - 1);\n      setTarget(to);\n      if (to !== live.current) {\n        live.current = to;\n        setUncontrolled(to);\n        changed.current?.(to);\n      }\n      glide(-to * shelf.step, velocity);\n    },\n    [glide],\n  );\n\n  const bounce = useCallback(\n    (dir: 1 | -1) => {\n      const shelf = metrics.current;\n      const to = -live.current * shelf.step;\n      desired.current = to;\n      anim.current?.stop();\n      anim.current = animate(\n        x,\n        to,\n        reduced ? { duration: 0 } : { ...WALL, velocity: -dir * WALL_IMPULSE },\n      );\n    },\n    [x, reduced],\n  );\n\n  const move = useCallback(\n    (dir: 1 | -1) => {\n      const to = live.current + dir;\n      if (to < 0 || to > metrics.current.total - 1) bounce(dir);\n      else goTo(to);\n    },\n    [bounce, goTo],\n  );\n\n  const next = useCallback(() => move(1), [move]);\n  const prev = useCallback(() => move(-1), [move]);\n\n  const pick = useCallback(\n    (velocity: number) => {\n      const shelf = metrics.current;\n      if (shelf.step === 0) return live.current;\n      const at = -x.get() / shelf.step;\n      const anchor = clamp(Math.round(at), 0, shelf.total - 1);\n      const projected = at - (velocity * shelf.momentum) / shelf.step;\n      return clamp(\n        clamp(\n          Math.round(projected),\n          anchor - shelf.maxFlick,\n          anchor + shelf.maxFlick,\n        ),\n        0,\n        shelf.total - 1,\n      );\n    },\n    [x],\n  );\n\n  useIsomorphicLayoutEffect(() => {\n    const el = viewportRef.current;\n    if (!el) return;\n    const observer = new ResizeObserver((entries) => {\n      const width = entries[0]?.contentRect.width ?? 0;\n      setSlideWidth((current) =>\n        Math.abs(current - width) < 0.5 ? current : width,\n      );\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  useIsomorphicLayoutEffect(() => {\n    if (step === 0) return;\n    const to = -index * step;\n\n    if (lastStep.current !== step) {\n      lastStep.current = step;\n      desired.current = to;\n      anim.current?.stop();\n      x.set(to);\n      return;\n    }\n    if (dragging || desired.current === to) return;\n    glide(to);\n  }, [index, step, dragging, glide, x]);\n\n  useEffect(() => () => anim.current?.stop(), []);\n\n  const onDragStart = useCallback(() => {\n    anim.current?.stop();\n    setDragging(true);\n    setTarget(live.current);\n  }, []);\n\n  const onDrag = useCallback(\n    (_event: MouseEvent | TouchEvent | PointerEvent, info: DragInfo) => {\n      const to = pick(info.velocity.x);\n      setTarget((current) => (current === to ? current : to));\n    },\n    [pick],\n  );\n\n  const onDragEnd = useCallback(\n    (_event: MouseEvent | TouchEvent | PointerEvent, info: DragInfo) => {\n      setDragging(false);\n      goTo(pick(info.velocity.x), info.velocity.x);\n    },\n    [goTo, pick],\n  );\n\n  const onKeyDown = useCallback(\n    (event: React.KeyboardEvent) => {\n      if (event.key === \"ArrowRight\") {\n        event.preventDefault();\n        next();\n      } else if (event.key === \"ArrowLeft\") {\n        event.preventDefault();\n        prev();\n      } else if (event.key === \"Home\") {\n        event.preventDefault();\n        goTo(0);\n      } else if (event.key === \"End\") {\n        event.preventDefault();\n        goTo(metrics.current.total - 1);\n      }\n    },\n    [goTo, next, prev],\n  );\n\n  const onScroll = useCallback((event: React.UIEvent<HTMLElement>) => {\n    event.currentTarget.scrollLeft = 0;\n    event.currentTarget.scrollTop = 0;\n  }, []);\n\n  const viewportProps = {\n    tabIndex: 0,\n    role: \"group\" as const,\n    \"aria-roledescription\": \"carousel\",\n    onKeyDown,\n    onScroll,\n  };\n\n  const trackProps = {\n    drag: (disabled || total < 2 ? false : \"x\") as false | \"x\",\n    dragDirectionLock: true,\n    dragMomentum: false,\n    dragElastic: 0.14,\n    dragConstraints: { left: -(total - 1) * step, right: 0 },\n    onDragStart,\n    onDrag,\n    onDragEnd,\n    style: { x, gap: `${gap}px`, touchAction: \"pan-y\" as const },\n  };\n\n  return {\n    index,\n    count: total,\n    target,\n    shown: dragging ? target : index,\n    dragging,\n    slideWidth,\n    step,\n    x,\n    goTo,\n    next,\n    prev,\n    viewportRef,\n    viewportProps,\n    trackProps,\n  };\n}\n\nexport type UseSnapCarouselResult = ReturnType<typeof useSnapCarousel>;\n\nconst CARET_LEFT = (\n  <svg width=\"14\" height=\"14\" viewBox=\"0 0 256 256\" fill=\"none\" aria-hidden=\"true\">\n    <polyline\n      points=\"160 208 80 128 160 48\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\nconst CARET_RIGHT = (\n  <svg width=\"14\" height=\"14\" viewBox=\"0 0 256 256\" fill=\"none\" aria-hidden=\"true\">\n    <polyline\n      points=\"96 48 176 128 96 208\"\n      stroke=\"currentColor\"\n      strokeWidth=\"16\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    />\n  </svg>\n);\n\nexport type SnapCarouselProps = {\n  children: React.ReactNode;\n  label: string;\n  index?: number;\n  defaultIndex?: number;\n  onIndexChange?: (index: number) => void;\n  gap?: number;\n  peek?: number;\n  momentum?: number;\n  maxFlick?: number;\n  prevLabel?: string;\n  nextLabel?: string;\n  className?: string;\n};\n\nexport function SnapCarousel({\n  children,\n  label,\n  index,\n  defaultIndex = 0,\n  onIndexChange,\n  gap = 12,\n  peek = 0,\n  momentum = 0.14,\n  maxFlick = 1,\n  prevLabel = \"Previous slide\",\n  nextLabel = \"Next slide\",\n  className = \"\",\n}: SnapCarouselProps) {\n  const slides = Children.toArray(children);\n  const hintId = useId();\n  const reduced = useReducedMotion();\n\n  const car = useSnapCarousel({\n    count: slides.length,\n    index,\n    defaultIndex,\n    onIndexChange,\n    gap,\n    momentum,\n    maxFlick,\n  });\n\n  const button =\n    \"grid size-7 place-items-center rounded-[6px] border border-stone-200 bg-white text-stone-700 shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),inset_0_-1px_0_rgba(28,25,23,0.06),0_1px_2px_rgba(28,25,23,0.08)] outline-none transition-[background-color,border-color,box-shadow,transform] duration-150 hover:bg-stone-50 active:translate-y-px active:shadow-[inset_0_1px_2px_rgba(28,25,23,0.06)] focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] dark:border-white/[0.16] dark:bg-[#252522] dark:text-stone-200 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:bg-[#2A2A27] dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)]\";\n\n  return (\n    <div className={`w-full ${className}`}>\n      <div\n        ref={car.viewportRef}\n        aria-label={label}\n        aria-describedby={hintId}\n        style={{\n          paddingLeft: peek,\n          paddingRight: peek,\n          ...(peek > 0\n            ? {\n                WebkitMaskImage: `linear-gradient(to right, transparent 0, black ${peek + 14}px, black calc(100% - ${peek + 14}px), transparent 100%)`,\n                maskImage: `linear-gradient(to right, transparent 0, black ${peek + 14}px, black calc(100% - ${peek + 14}px), transparent 100%)`,\n              }\n            : {}),\n        }}\n        className=\"relative overflow-hidden rounded-[14px] py-1.5 outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n        {...car.viewportProps}\n      >\n        <motion.div\n          {...car.trackProps}\n          className={`flex items-stretch ${\n            car.dragging ? \"cursor-grabbing\" : \"cursor-grab\"\n          }`}\n        >\n          {slides.map((slide, i) => (\n            <motion.div\n              key={isValidElement(slide) && slide.key ? slide.key : i}\n              role=\"group\"\n              aria-roledescription=\"slide\"\n              aria-label={`${i + 1} of ${slides.length}`}\n              inert={i !== car.index}\n              initial={false}\n              animate={\n                i === car.shown\n                  ? { scale: 1, opacity: 1 }\n                  : { scale: 0.96, opacity: 0.55 }\n              }\n              transition={reduced ? { duration: 0 } : CROSSFADE}\n              className=\"w-full shrink-0 select-none\"\n            >\n              {slide}\n            </motion.div>\n          ))}\n        </motion.div>\n      </div>\n      <div className=\"mt-3 flex items-center justify-between gap-3\">\n        <span className=\"flex items-center gap-[3px]\">\n          {slides.map((slide, i) => (\n            <button\n              key={isValidElement(slide) && slide.key ? slide.key : i}\n              type=\"button\"\n              onClick={() => car.goTo(i)}\n              aria-label={`Go to slide ${i + 1}`}\n              aria-current={i === car.index ? \"true\" : undefined}\n              className=\"grid h-[18px] w-[16px] place-items-center rounded-[5px] outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n            >\n              <motion.span\n                initial={false}\n                animate={\n                  i === car.shown\n                    ? { scaleX: 1, opacity: 1 }\n                    : { scaleX: 0.36, opacity: 0.26 }\n                }\n                transition={reduced ? { duration: 0 } : CELL}\n                className=\"block h-[5px] w-[14px] rounded-[1.5px] bg-stone-800 dark:bg-stone-100\"\n              />\n            </button>\n          ))}\n        </span>\n        <span className=\"flex items-center gap-1.5\">\n          <button type=\"button\" onClick={car.prev} aria-label={prevLabel} className={button}>\n            {CARET_LEFT}\n          </button>\n          <button type=\"button\" onClick={car.next} aria-label={nextLabel} className={button}>\n            {CARET_RIGHT}\n          </button>\n        </span>\n      </div>\n      <span id={hintId} className=\"sr-only\">\n        Left and right arrow keys move between {slides.length} slides.\n      </span>\n      <span aria-live=\"polite\" aria-atomic className=\"sr-only\">\n        Slide {car.index + 1} of {slides.length}\n      </span>\n    </div>\n  );\n}\n"}]}