{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "snap-carousel",
  "type": "registry:ui",
  "title": "Snap Carousel",
  "description": "An accessible carousel on native CSS scroll-snap with peeking slides, dot or thumbnail pickers and pausable autoplay.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/snap-carousel.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface SnapCarouselSlide {\n  /** Unique key for the slide. */\n  id: string;\n  /** Slide content. The slide wrapper only sets width and snapping; style the card yourself. */\n  content: React.ReactNode;\n  /** Short accessible name, announced with the position, e.g. \"Arc Lamp\". */\n  label?: string;\n  /** Small preview rendered in the thumbnail picker. Falls back to the slide number. */\n  thumbnail?: React.ReactNode;\n}\n\nexport interface SnapCarouselProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  slides: SnapCarouselSlide[];\n  /** Accessible name of the carousel region. */\n  label?: string;\n  /** Slides per view in a wide container. Narrow containers drop to 2, then 1 (container queries). */\n  slidesPerView?: number;\n  /** How much of the next slide peeks into view, in px. */\n  peek?: number;\n  /** Gap between slides in px. */\n  gap?: number;\n  /** Where slides snap: to the start edge or the centre of the viewport. */\n  align?: \"start\" | \"center\";\n  /** Slide picker under the track. */\n  indicators?: \"dots\" | \"thumbnails\" | \"none\";\n  /** Advance automatically until the last slide (no loop). Shows a pause / play control. */\n  autoplay?: boolean;\n  /** Time per slide while autoplaying, in ms. */\n  interval?: number;\n  /** Accent for the active indicator, autoplay ring and focus rings. */\n  accent?: string;\n  /** Called when the active slide changes (after scrolling settles on it). */\n  onIndexChange?: (index: number) => void;\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nfunction subscribeVisibility(callback: () => void) {\n  document.addEventListener(\"visibilitychange\", callback);\n  return () => document.removeEventListener(\"visibilitychange\", callback);\n}\n\nfunction useDocumentHidden() {\n  return React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);\n}\n\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\nconst pad = (n: number) => String(n).padStart(2, \"0\");\nconst RING = 2 * Math.PI * 9; // circumference of the autoplay progress ring (r = 9)\n\n/**\n * A carousel on native CSS scroll-snap: the browser does the swiping, snapping and momentum, while\n * IntersectionObserver reports which slides are in view. Prev / next buttons disable at the ends, the\n * dot or thumbnail picker follows the active slide, slides-per-view responds to the container width, and\n * optional autoplay runs once to the end with a visible pause control (WCAG 2.2.2).\n */\nexport function SnapCarousel({\n  slides,\n  label = \"Carousel\",\n  slidesPerView = 3,\n  peek = 40,\n  gap = 16,\n  align = \"start\",\n  indicators = \"dots\",\n  autoplay = false,\n  interval = 5000,\n  accent = \"#FF7448\",\n  onIndexChange,\n  className = \"\",\n  style,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: SnapCarouselProps) {\n  const id = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const documentHidden = useDocumentHidden();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const scrollerRef = React.useRef<HTMLDivElement>(null);\n  const toggleRef = React.useRef<HTMLButtonElement>(null);\n  const ringRef = React.useRef<SVGCircleElement>(null);\n  const animationRef = React.useRef<Animation | null>(null);\n  const ratiosRef = React.useRef<number[]>([]);\n  const activeRef = React.useRef(0);\n  // Slide we are scrolling towards, so rapid clicks step from the target rather than the stale position.\n  const pendingRef = React.useRef<number | null>(null);\n\n  const count = slides.length;\n  const perView = clamp(Math.round(slidesPerView), 1, 6);\n  const [active, setActive] = React.useState(0);\n  const [range, setRange] = React.useState<[number, number]>([0, 0]);\n  const [edges, setEdges] = React.useState({ prev: false, next: count > 1 });\n  const [inView, setInView] = React.useState(true);\n  const [hovered, setHovered] = React.useState(false);\n  const [focusInside, setFocusInside] = React.useState(false);\n  const [announce, setAnnounce] = React.useState(false);\n\n  // Autoplay starts on its own, except under reduced motion where it waits for the play button.\n  const [playing, setPlaying] = React.useState(autoplay);\n  const [userToggled, setUserToggled] = React.useState(false);\n  const [prevAutoplay, setPrevAutoplay] = React.useState(autoplay);\n  if (prevAutoplay !== autoplay) {\n    setPrevAutoplay(autoplay);\n    setPlaying(autoplay);\n    setUserToggled(false);\n  }\n  const isPlaying = autoplay && playing && (!reduceMotion || userToggled);\n  const running = isPlaying && !hovered && !focusInside && inView && !documentHidden;\n\n  /* ----------------------------- active tracking ---------------------------- */\n\n  const reportIndex = React.useEffectEvent((index: number) => onIndexChange?.(index));\n\n  React.useEffect(() => {\n    const scroller = scrollerRef.current;\n    if (!scroller) return;\n    const nodes = Array.from(scroller.children) as HTMLElement[];\n    ratiosRef.current = nodes.map(() => 0);\n    const observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          const index = Number((entry.target as HTMLElement).dataset.index);\n          ratiosRef.current[index] = entry.intersectionRatio;\n        }\n        const ratios = ratiosRef.current;\n        const visible = ratios.flatMap((ratio, i) => (ratio >= 0.9 ? [i] : []));\n        let next: number;\n        if (visible.length) next = align === \"center\" ? visible[Math.floor((visible.length - 1) / 2)] : visible[0];\n        else next = ratios.reduce((best, ratio, i) => (ratio > ratios[best] ? i : best), 0);\n        if (pendingRef.current === next) pendingRef.current = null;\n        const first = visible[0] ?? next;\n        const last = visible[visible.length - 1] ?? next;\n        setRange((prev) => (prev[0] === first && prev[1] === last ? prev : [first, last]));\n        if (activeRef.current !== next) {\n          activeRef.current = next;\n          setActive(next);\n          reportIndex(next);\n        }\n      },\n      { root: scroller, threshold: [0, 0.25, 0.5, 0.75, 0.9, 1] },\n    );\n    nodes.forEach((node) => observer.observe(node));\n    return () => observer.disconnect();\n  }, [count, align]);\n\n  // Prev / next availability follows the real scroll position (rAF-throttled, state only on change).\n  React.useEffect(() => {\n    const scroller = scrollerRef.current;\n    if (!scroller) return;\n    let frame = 0;\n    const measure = () => {\n      frame = 0;\n      const max = scroller.scrollWidth - scroller.clientWidth;\n      const prev = scroller.scrollLeft > 2;\n      const next = scroller.scrollLeft < max - 2;\n      setEdges((e) => (e.prev === prev && e.next === next ? e : { prev, next }));\n    };\n    const schedule = () => {\n      if (!frame) frame = requestAnimationFrame(measure);\n    };\n    measure();\n    scroller.addEventListener(\"scroll\", schedule, { passive: true });\n    const ro = new ResizeObserver(schedule);\n    ro.observe(scroller);\n    return () => {\n      scroller.removeEventListener(\"scroll\", schedule);\n      ro.disconnect();\n      cancelAnimationFrame(frame);\n    };\n  }, [count]);\n\n  // Autoplay only runs while the carousel is on screen.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root || !autoplay) return;\n    const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [autoplay]);\n\n  /* -------------------------------- navigation ------------------------------- */\n\n  const scrollToIndex = (index: number) => {\n    const scroller = scrollerRef.current;\n    const slide = scroller?.children[index] as HTMLElement | undefined;\n    if (!scroller || !slide) return;\n    const max = scroller.scrollWidth - scroller.clientWidth;\n    const left =\n      align === \"center\" ? slide.offsetLeft + slide.offsetWidth / 2 - scroller.clientWidth / 2 : slide.offsetLeft;\n    pendingRef.current = index;\n    scroller.scrollTo({ left: clamp(left, 0, max), behavior: reduceMotion ? \"auto\" : \"smooth\" });\n  };\n\n  const atEnd = () => {\n    const scroller = scrollerRef.current;\n    return !scroller || scroller.scrollLeft >= scroller.scrollWidth - scroller.clientWidth - 2;\n  };\n\n  /** Moves by one slide. Returns false when there is nowhere to go. */\n  const step = (direction: 1 | -1) => {\n    const from = pendingRef.current ?? active;\n    const target = clamp(from + direction, 0, count - 1);\n    if (target === from || (direction > 0 && atEnd() && pendingRef.current === null)) return false;\n    scrollToIndex(target);\n    return true;\n  };\n\n  const userNavigate = (fn: () => void) => {\n    setAnnounce(true);\n    fn();\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (event.target !== event.currentTarget) return;\n    const keys: Record<string, () => void> = {\n      ArrowRight: () => step(1),\n      ArrowLeft: () => step(-1),\n      Home: () => scrollToIndex(0),\n      End: () => scrollToIndex(count - 1),\n    };\n    const action = keys[event.key];\n    if (!action) return;\n    event.preventDefault();\n    userNavigate(action);\n  };\n\n  /* --------------------------------- autoplay -------------------------------- */\n\n  const advance = React.useEffectEvent(() => {\n    setAnnounce(false);\n    if (!step(1)) setPlaying(false);\n  });\n\n  // One Web Animation per slide drives the progress ring; finishing it advances the carousel.\n  // Pausing the animation (hover, focus, hidden tab, offscreen) pauses the timer at the same point.\n  React.useEffect(() => {\n    const ring = ringRef.current;\n    if (!isPlaying || !ring) return;\n    const animation = ring.animate([{ strokeDashoffset: RING }, { strokeDashoffset: 0 }], {\n      duration: interval,\n      easing: \"linear\",\n      fill: \"both\",\n    });\n    animation.pause();\n    animation.onfinish = () => advance();\n    animationRef.current = animation;\n    return () => {\n      animation.onfinish = null;\n      animation.cancel();\n      animationRef.current = null;\n    };\n  }, [isPlaying, active, interval]);\n\n  React.useEffect(() => {\n    const animation = animationRef.current;\n    if (!animation) return;\n    if (running) animation.play();\n    else animation.pause();\n  }, [running, isPlaying, active, interval]);\n\n  const togglePlaying = () => {\n    setUserToggled(true);\n    if (!isPlaying && atEnd()) {\n      // Finished: start again from the first slide.\n      scrollToIndex(0);\n    }\n    setPlaying(!isPlaying);\n  };\n\n  /* ---------------------------------- render --------------------------------- */\n\n  const transitionDots = reduceMotion ? \"\" : \"transition-[width,background-color,opacity] duration-300\";\n  const ringFocus =\n    \"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sc-accent)]\";\n  const arrowClass = `flex size-9 items-center justify-center rounded-full border border-zinc-900/10 bg-white text-zinc-700 shadow-[0_1px_2px_rgba(0,0,0,0.05)] transition-[color,border-color,opacity] hover:border-zinc-900/25 hover:text-zinc-950 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:border-zinc-900/10 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-white/25 dark:hover:text-white dark:disabled:hover:border-white/10 ${ringFocus}`;\n  const showRange = range[1] > range[0];\n\n  return (\n    <section\n      ref={rootRef}\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      className={`@container w-full min-w-0 ${className}`}\n      style={\n        {\n          \"--sc-accent\": accent,\n          \"--sc-max\": perView,\n          \"--sc-gap\": `${gap}px`,\n          \"--sc-peek\": `${align === \"center\" ? peek * 2 : peek}px`,\n          ...style,\n        } as React.CSSProperties\n      }\n      onPointerEnter={(event) => {\n        if (event.pointerType === \"mouse\") setHovered(true);\n        onPointerEnter?.(event);\n      }}\n      onPointerLeave={(event) => {\n        setHovered(false);\n        onPointerLeave?.(event);\n      }}\n      onFocus={(event) => {\n        // Focus anywhere except the pause / play button pauses rotation.\n        setFocusInside(event.target !== toggleRef.current);\n        onFocus?.(event);\n      }}\n      onBlur={(event) => {\n        const next = event.relatedTarget as Node | null;\n        if (!next || !event.currentTarget.contains(next)) setFocusInside(false);\n        onBlur?.(event);\n      }}\n      {...props}\n    >\n      <div\n        ref={scrollerRef}\n        id={`${id}-track`}\n        tabIndex={0}\n        role=\"group\"\n        aria-label={`${label} slides`}\n        aria-describedby={`${id}-hint`}\n        onKeyDown={handleKeyDown}\n        onPointerDown={() => (pendingRef.current = null)}\n        onWheel={() => (pendingRef.current = null)}\n        className={`relative flex snap-x snap-mandatory overflow-x-auto overflow-y-hidden overscroll-x-contain rounded-2xl [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [--sc-per:1] @min-[440px]:[--sc-per:min(2,var(--sc-max))] @min-[720px]:[--sc-per:var(--sc-max)] ${ringFocus}`}\n        style={{ gap: \"var(--sc-gap)\" }}\n      >\n        {slides.map((slide, index) => (\n          <div\n            key={slide.id}\n            role=\"group\"\n            aria-roledescription=\"slide\"\n            aria-label={`${index + 1} of ${count}${slide.label ? `: ${slide.label}` : \"\"}`}\n            data-index={index}\n            className={`min-w-0 shrink-0 ${align === \"center\" ? \"snap-center\" : \"snap-start\"}`}\n            style={{\n              width: \"calc((100% - var(--sc-peek) - (var(--sc-per) - 1) * var(--sc-gap)) / var(--sc-per))\",\n            }}\n          >\n            {slide.content}\n          </div>\n        ))}\n      </div>\n\n      <div className=\"mt-4 flex items-center gap-3\">\n        {autoplay && (\n          <button\n            ref={toggleRef}\n            type=\"button\"\n            onClick={togglePlaying}\n            aria-label={isPlaying ? \"Pause automatic slide show\" : \"Start automatic slide show\"}\n            aria-controls={`${id}-track`}\n            className={`relative flex size-9 shrink-0 items-center justify-center rounded-full text-zinc-700 transition-colors hover:text-zinc-950 dark:text-zinc-300 dark:hover:text-white ${ringFocus}`}\n          >\n            <svg viewBox=\"0 0 22 22\" className=\"absolute inset-0 size-9 -rotate-90\" aria-hidden=\"true\">\n              <circle cx=\"11\" cy=\"11\" r=\"9\" fill=\"none\" strokeWidth=\"1.25\" className=\"stroke-zinc-900/12 dark:stroke-white/15\" />\n              <circle\n                ref={ringRef}\n                cx=\"11\"\n                cy=\"11\"\n                r=\"9\"\n                fill=\"none\"\n                strokeWidth=\"1.5\"\n                strokeLinecap=\"round\"\n                stroke=\"var(--sc-accent)\"\n                strokeDasharray={RING.toFixed(2)}\n                strokeDashoffset={RING.toFixed(2)}\n                className={isPlaying ? \"\" : \"opacity-0\"}\n              />\n            </svg>\n            <svg viewBox=\"0 0 16 16\" className=\"relative size-3.5\" fill=\"currentColor\" aria-hidden=\"true\">\n              {isPlaying ? (\n                <path d=\"M4.5 3h2.25v10H4.5zM9.25 3h2.25v10H9.25z\" />\n              ) : (\n                <path d=\"M5 2.8v10.4a.5.5 0 0 0 .77.42l8-5.2a.5.5 0 0 0 0-.84l-8-5.2A.5.5 0 0 0 5 2.8Z\" />\n              )}\n            </svg>\n          </button>\n        )}\n\n        {indicators !== \"none\" && count > 1 && (\n          <div\n            role=\"group\"\n            aria-label=\"Choose slide\"\n            className={`flex min-w-0 flex-1 items-center ${indicators === \"thumbnails\" ? \"gap-2 overflow-x-auto p-0.5 [scrollbar-width:none]\" : \"gap-1.5\"}`}\n          >\n            {slides.map((slide, index) => {\n              const isActive = index === active;\n              const inRange = index >= range[0] && index <= range[1];\n              const name = `Slide ${index + 1}${slide.label ? `: ${slide.label}` : \"\"}`;\n              return indicators === \"thumbnails\" ? (\n                <button\n                  key={slide.id}\n                  type=\"button\"\n                  aria-label={name}\n                  aria-current={isActive ? \"true\" : undefined}\n                  aria-controls={`${id}-track`}\n                  onClick={() => userNavigate(() => scrollToIndex(index))}\n                  className={`relative size-12 shrink-0 overflow-hidden rounded-lg border bg-zinc-100 dark:bg-zinc-900 ${transitionDots} ${\n                    isActive\n                      ? \"border-[var(--sc-accent)] opacity-100\"\n                      : inRange\n                        ? \"border-zinc-900/15 opacity-90 dark:border-white/15\"\n                        : \"border-zinc-900/10 opacity-55 hover:opacity-90 dark:border-white/10\"\n                  } ${ringFocus}`}\n                >\n                  {slide.thumbnail ?? (\n                    <span className=\"font-mono text-[10px] tabular-nums text-zinc-500 dark:text-zinc-400\">{pad(index + 1)}</span>\n                  )}\n                  {isActive && <span aria-hidden=\"true\" className=\"absolute inset-x-1.5 bottom-1 h-[2px] rounded-full bg-[var(--sc-accent)]\" />}\n                </button>\n              ) : (\n                <button\n                  key={slide.id}\n                  type=\"button\"\n                  aria-label={name}\n                  aria-current={isActive ? \"true\" : undefined}\n                  aria-controls={`${id}-track`}\n                  onClick={() => userNavigate(() => scrollToIndex(index))}\n                  // 24px hit area around a small visual dot.\n                  className={`group flex h-6 items-center justify-center rounded-full ${isActive ? \"w-7\" : \"w-4\"} ${transitionDots} ${ringFocus}`}\n                >\n                  <span\n                    aria-hidden=\"true\"\n                    className={`block h-1.5 rounded-full ${transitionDots} ${\n                      isActive\n                        ? \"w-6 bg-[var(--sc-accent)]\"\n                        : inRange\n                          ? \"w-1.5 bg-zinc-900/45 dark:bg-white/50\"\n                          : \"w-1.5 bg-zinc-900/15 group-hover:bg-zinc-900/35 dark:bg-white/20 dark:group-hover:bg-white/40\"\n                    }`}\n                  />\n                </button>\n              );\n            })}\n          </div>\n        )}\n        {(indicators === \"none\" || count <= 1) && <div className=\"flex-1\" />}\n\n        <p aria-hidden=\"true\" className=\"shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums text-zinc-500 dark:text-zinc-400\">\n          <span className=\"text-zinc-900 dark:text-zinc-100\">{showRange ? `${pad(range[0] + 1)}–${pad(range[1] + 1)}` : pad(active + 1)}</span>\n          {\" / \"}\n          {pad(count)}\n        </p>\n\n        <div className=\"flex shrink-0 items-center gap-1.5\">\n          <button\n            type=\"button\"\n            aria-label=\"Previous slide\"\n            aria-controls={`${id}-track`}\n            disabled={!edges.prev}\n            onClick={() => userNavigate(() => step(-1))}\n            className={arrowClass}\n          >\n            <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" aria-hidden=\"true\">\n              <path d=\"M10 3.5 5.5 8l4.5 4.5\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n          </button>\n          <button\n            type=\"button\"\n            aria-label=\"Next slide\"\n            aria-controls={`${id}-track`}\n            disabled={!edges.next}\n            onClick={() => userNavigate(() => step(1))}\n            className={arrowClass}\n          >\n            <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" aria-hidden=\"true\">\n              <path d=\"M6 3.5 10.5 8 6 12.5\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n          </button>\n        </div>\n      </div>\n\n      <span id={`${id}-hint`} className=\"sr-only\">\n        Use the Left and Right arrow keys to move between slides.\n      </span>\n      {/* Announces the position after user navigation; silent while autoplay rotates (APG). */}\n      <div role=\"status\" aria-live={running ? \"off\" : \"polite\"} aria-atomic=\"true\" className=\"sr-only\">\n        {announce ? `Slide ${active + 1} of ${count}${slides[active]?.label ? `: ${slides[active].label}` : \"\"}` : \"\"}\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/snap-carousel"
}
