{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "snap-carousel",
  "type": "registry:ui",
  "title": "Snap Carousel",
  "description": "Native scroll-snap carousel with sprung paging, mouse drag and dots that follow the scroll.",
  "categories": [
    "scroll"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/snap-carousel.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls, type MotionValue } from \"motion/react\";\nimport { createContext, use, useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronLeft, ChevronRight } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\n\ntype Nav = \"button\" | \"key\" | \"dot\" | \"drag\";\n\ntype Ctx = {\n  viewport: React.RefObject<HTMLDivElement | null>;\n  track: React.RefObject<HTMLDivElement | null>;\n  count: number;\n  active: number;\n  atStart: boolean;\n  atEnd: boolean;\n  dragging: boolean;\n  /** Continuous page position: 1.5 is halfway between the second and third page. */\n  progress: MotionValue<number>;\n  go: (page: number, via: Nav) => void;\n  step: (dir: 1 | -1, via: Nav) => void;\n  label: string;\n  viewportId: string;\n  onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;\n  onClickCapture: (e: React.MouseEvent<HTMLDivElement>) => void;\n  reveal: (slide: HTMLElement) => void;\n};\n\nconst CarouselContext = createContext<Ctx | null>(null);\nfunction useCarousel(part: string) {\n  const ctx = use(CarouselContext);\n  if (!ctx) throw new Error(`<${part}> must be inside <SnapCarousel>`);\n  return ctx;\n}\n\n/** Scroll offsets the viewport can rest at, one per page, measured from the slides. */\nfunction measurePages(viewport: HTMLElement) {\n  const max = viewport.scrollWidth - viewport.clientWidth;\n  const pad = parseFloat(getComputedStyle(viewport).scrollPaddingInlineStart) || 0;\n  const pages: number[] = [];\n  for (const slide of viewport.querySelectorAll<HTMLElement>(\"[data-carousel-slide]\")) {\n    const at = Math.max(0, Math.min(max, slide.offsetLeft - pad));\n    // Slides near the end all clamp to the last offset; they share one page.\n    if (!pages.length || at - pages[pages.length - 1] > 2) pages.push(at);\n  }\n  if (!pages.length) pages.push(0);\n  return { pages, max };\n}\n\nexport type SnapCarouselProps = React.ComponentProps<\"section\"> & {\n  /** Names the carousel for screen readers, e.g. \"Templates\". */\n  label: string;\n  /** Controlled page index (a page is one resting position, which may show several slides). */\n  page?: number;\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\n  /** Click-and-drag with a mouse. Touch and trackpads always scroll natively. */\n  draggable?: boolean;\n};\n\n/**\n * Root. Size slides and spacing with CSS variables on it:\n * --slide-size (default 100%), --slide-gap (12px), --gutter (0px, inset at both ends).\n */\nexport function SnapCarousel({ label, page, defaultPage = 0, onPageChange, draggable = true, className, children, ...rest }: SnapCarouselProps) {\n  const reduce = useReducedMotion();\n  const viewport = useRef<HTMLDivElement>(null);\n  const track = useRef<HTMLDivElement>(null);\n  const progress = useMotionValue(defaultPage);\n  const [count, setCount] = useState(1);\n  const [active, setActive] = useState(defaultPage);\n  const [edges, setEdges] = useState({ start: defaultPage === 0, end: false });\n  const [dragging, setDragging] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n  const viewportId = useId();\n\n  const pagesRef = useRef<number[]>([0]);\n  const glide = useRef<AnimationPlaybackControls | null>(null);\n  const drag = useRef<{ id: number; x: number; left: number; moved: boolean; t: number; v: number; last: number } | null>(null);\n  const activeRef = useRef(defaultPage);\n  const onChangeRef = useRef(onPageChange);\n  useEffect(() => {\n    onChangeRef.current = onPageChange;\n  });\n\n  // Reads the scroll position once per frame: the dots and counter follow it\n  // continuously, React only hears about it when the page actually changes.\n  const sync = useCallback(() => {\n    const el = viewport.current;\n    if (!el) return;\n    const pages = pagesRef.current;\n    const x = el.scrollLeft;\n    let p = pages.length - 1;\n    for (let i = 0; i < pages.length - 1; i++) {\n      if (x < pages[i + 1]) {\n        p = i + Math.max(0, (x - pages[i]) / (pages[i + 1] - pages[i] || 1));\n        break;\n      }\n    }\n    progress.set(p);\n    const nearest = Math.round(p);\n    if (nearest !== activeRef.current) {\n      activeRef.current = nearest;\n      setActive(nearest);\n      onChangeRef.current?.(nearest);\n    }\n    const max = el.scrollWidth - el.clientWidth;\n    setEdges((prev) => {\n      const next = { start: x <= 1, end: x >= max - 1 };\n      return prev.start === next.start && prev.end === next.end ? prev : next;\n    });\n  }, [progress]);\n\n  const measure = useCallback(() => {\n    const el = viewport.current;\n    if (!el) return;\n    pagesRef.current = measurePages(el).pages;\n    setCount(pagesRef.current.length);\n    sync();\n  }, [sync]);\n\n  const stopGlide = () => {\n    glide.current?.stop();\n    glide.current = null;\n    viewport.current?.removeAttribute(\"data-gliding\");\n  };\n\n  // Scroll to an offset on a spring. Snapping is paused while it runs, or the\n  // browser would re-snap every frame and fight it. Reduced motion jumps.\n  const scrollToX = useCallback(\n    (target: number, velocity = 0, done?: () => void) => {\n      const el = viewport.current;\n      if (!el) return;\n      glide.current?.stop();\n      if (reduce) {\n        el.scrollLeft = target;\n        done?.();\n        return;\n      }\n      el.setAttribute(\"data-gliding\", \"\");\n      glide.current = animate(el.scrollLeft, target, {\n        ...spring.sheet,\n        velocity,\n        restDelta: 0.5,\n        onUpdate: (v) => (el.scrollLeft = v),\n        onComplete: () => {\n          el.removeAttribute(\"data-gliding\");\n          glide.current = null;\n          done?.();\n        },\n      });\n    },\n    [reduce],\n  );\n\n  // Where a glide is heading, so pressing Next three times fast moves three pages.\n  const target = useRef(defaultPage);\n  const go = useCallback(\n    (to: number, via: Nav) => {\n      const pages = pagesRef.current;\n      const i = Math.max(0, Math.min(pages.length - 1, to));\n      target.current = i;\n      // Settled: tell screen readers where they landed, once.\n      scrollToX(pages[i], 0, () => setAnnounce(via === \"drag\" ? \"\" : `Page ${i + 1} of ${pages.length}`));\n    },\n    [scrollToX],\n  );\n\n  // Next/previous from wherever the scroll is now, even mid-swipe between pages.\n  const step = useCallback(\n    (dir: 1 | -1, via: Nav) => {\n      const el = viewport.current;\n      if (!el) return;\n      const pages = pagesRef.current;\n      const x = glide.current ? pages[target.current] : el.scrollLeft;\n      const i = dir > 0 ? pages.findIndex((p) => p > x + 2) : pages.findLastIndex((p) => p < x - 2);\n      if (i >= 0) go(i, via);\n    },\n    [go],\n  );\n\n  // Tabbing into a slide that is cut off at an edge moves to the page that\n  // shows all of it. Keyboard-driven, so it jumps rather than glides.\n  const reveal = useCallback((slide: HTMLElement) => {\n    const el = viewport.current;\n    if (!el || drag.current) return;\n    const pad = parseFloat(getComputedStyle(el).scrollPaddingInlineStart) || 0;\n    const start = slide.offsetLeft - pad;\n    const end = start + slide.offsetWidth;\n    const x = el.scrollLeft;\n    if (start >= x - 1 && end <= x + el.clientWidth - 2 * pad + 1) return;\n    // The smallest move that shows all of it: forward to the first page whose\n    // right edge clears it, or back to the last page that starts before it.\n    const pages = pagesRef.current;\n    const span = el.clientWidth - 2 * pad;\n    let i = pages.length - 1;\n    if (start < x) {\n      i = 0;\n      for (let k = 0; k < pages.length; k++) if (pages[k] <= start + 1) i = k;\n    } else {\n      const k = pages.findIndex((p) => p + span >= end - 1);\n      if (k >= 0) i = k;\n    }\n    glide.current?.stop();\n    glide.current = null;\n    el.removeAttribute(\"data-gliding\");\n    target.current = i;\n    el.scrollLeft = pages[i];\n  }, []);\n\n  // Controlled page: follow the prop when it changes from outside.\n  useEffect(() => {\n    if (page === undefined || page === activeRef.current) return;\n    go(page, \"button\");\n  }, [page, go]);\n\n  useEffect(() => {\n    const el = viewport.current;\n    if (!el) return;\n    if (defaultPage) {\n      pagesRef.current = measurePages(el).pages;\n      el.scrollLeft = pagesRef.current[Math.min(defaultPage, pagesRef.current.length - 1)];\n    }\n    let frame = 0;\n    const onScroll = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(sync);\n    };\n    // Any hand on the scroller cancels a glide in flight.\n    const interrupt = () => stopGlide();\n    const ro = new ResizeObserver(() => measure());\n    ro.observe(el);\n    if (track.current) ro.observe(track.current);\n    el.addEventListener(\"scroll\", onScroll, { passive: true });\n    el.addEventListener(\"wheel\", interrupt, { passive: true });\n    el.addEventListener(\"touchstart\", interrupt, { passive: true });\n    measure();\n    return () => {\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      el.removeEventListener(\"scroll\", onScroll);\n      el.removeEventListener(\"wheel\", interrupt);\n      el.removeEventListener(\"touchstart\", interrupt);\n      glide.current?.stop();\n    };\n    // Mount-only: defaultPage is an initial value.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [measure, sync]);\n\n  // Mouse drag. Nothing happens until the pointer travels 5px, so clicks on\n  // links inside slides still work. Past either end the track rubber-bands.\n  const suppressClick = useRef(false);\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    const el = viewport.current;\n    if (!draggable || !el || e.pointerType !== \"mouse\" || e.button !== 0) return;\n    if ((e.target as HTMLElement).closest(\"input, textarea, select, [contenteditable], [data-carousel-nodrag]\")) return;\n    stopGlide();\n    drag.current = { id: e.pointerId, x: e.clientX, left: el.scrollLeft, moved: false, t: e.timeStamp, v: 0, last: e.clientX };\n\n    const move = (ev: PointerEvent) => {\n      const d = drag.current;\n      if (!d || ev.pointerId !== d.id) return;\n      const dx = ev.clientX - d.x;\n      if (!d.moved) {\n        if (Math.abs(dx) < 5) return;\n        d.moved = true;\n        el.setPointerCapture(d.id);\n        el.setAttribute(\"data-dragging\", \"\");\n        setDragging(true);\n      }\n      const dt = Math.max(1, ev.timeStamp - d.t);\n      d.v = 0.8 * ((ev.clientX - d.last) / dt) * 1000 + 0.2 * d.v;\n      d.t = ev.timeStamp;\n      d.last = ev.clientX;\n      const max = el.scrollWidth - el.clientWidth;\n      const want = d.left - dx;\n      el.scrollLeft = want;\n      // Rubber band: the overshoot moves the track at a third of the pointer's speed.\n      const over = want < 0 ? want : want > max ? want - max : 0;\n      if (track.current) track.current.style.transform = over ? `translateX(${-over * 0.3}px)` : \"\";\n    };\n\n    const up = (ev: PointerEvent) => {\n      const d = drag.current;\n      if (!d || ev.pointerId !== d.id) return;\n      window.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"pointerup\", up);\n      window.removeEventListener(\"pointercancel\", up);\n      drag.current = null;\n      if (!d.moved) return;\n      suppressClick.current = true;\n      el.removeAttribute(\"data-dragging\");\n      setDragging(false);\n      const t = track.current;\n      if (t?.style.transform) {\n        const from = parseFloat(t.style.transform.slice(11)) || 0;\n        if (reduce) t.style.transform = \"\";\n        else animate(from, 0, { ...spring.snappy, onUpdate: (v) => (t.style.transform = v ? `translateX(${v}px)` : \"\") });\n      }\n      // Project where the throw would land, then settle on the nearest page.\n      const pages = pagesRef.current;\n      const velocity = ev.type === \"pointercancel\" || ev.timeStamp - d.t > 80 ? 0 : d.v;\n      const projected = el.scrollLeft - velocity * 0.2;\n      let best = 0;\n      for (let i = 1; i < pages.length; i++) if (Math.abs(pages[i] - projected) < Math.abs(pages[best] - projected)) best = i;\n      // A flick always moves at least one page in the direction thrown.\n      const from = activeRef.current;\n      if (Math.abs(velocity) > 300 && best === from) best = Math.max(0, Math.min(pages.length - 1, from + (velocity < 0 ? 1 : -1)));\n      target.current = best;\n      scrollToX(pages[best], -velocity);\n    };\n\n    window.addEventListener(\"pointermove\", move);\n    window.addEventListener(\"pointerup\", up);\n    window.addEventListener(\"pointercancel\", up);\n  };\n\n  // A drag that ends over a link must not also follow it.\n  const onClickCapture = (e: React.MouseEvent<HTMLDivElement>) => {\n    if (!suppressClick.current) return;\n    suppressClick.current = false;\n    e.preventDefault();\n    e.stopPropagation();\n  };\n\n  const ctx = useMemo<Ctx>(\n    () => ({ viewport, track, count, active, atStart: edges.start, atEnd: edges.end, dragging, progress, go, step, label, viewportId, onPointerDown, onClickCapture, reveal }),\n    // onPointerDown and onClickCapture only read refs and stable setters.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [count, active, edges, dragging, progress, go, step, label, viewportId, reveal],\n  );\n\n  return (\n    <CarouselContext value={ctx}>\n      <section aria-roledescription=\"carousel\" aria-label={label} data-dragging={dragging || undefined} className={cn(\"relative min-w-0\", className)} {...rest}>\n        {children}\n        <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {announce}\n        </span>\n      </section>\n    </CarouselContext>\n  );\n}\n\nexport type SnapCarouselViewportProps = React.ComponentProps<\"div\">;\n\n/** The scroll container. Holds the slides. */\nexport function SnapCarouselViewport({ className, children, onKeyDown, onFocus, ...rest }: SnapCarouselViewportProps) {\n  const { viewport, track, step, go, count, viewportId, onPointerDown, onClickCapture, label, reveal } = useCarousel(\"SnapCarouselViewport\");\n  return (\n    <div\n      ref={viewport}\n      id={viewportId}\n      tabIndex={0}\n      role=\"group\"\n      aria-label={`${label} slides`}\n      onPointerDown={onPointerDown}\n      onClickCapture={onClickCapture}\n      onDragStart={(e) => e.preventDefault()}\n      onFocus={(e) => {\n        onFocus?.(e);\n        const slide = (e.target as HTMLElement).closest<HTMLElement>(\"[data-carousel-slide]\");\n        if (slide && e.target !== e.currentTarget) reveal(slide);\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.defaultPrevented || e.target !== e.currentTarget || e.altKey || e.metaKey || e.ctrlKey) return;\n        const map: Record<string, () => void> = {\n          ArrowRight: () => step(1, \"key\"),\n          ArrowLeft: () => step(-1, \"key\"),\n          Home: () => go(0, \"key\"),\n          End: () => go(count - 1, \"key\"),\n        };\n        if (!map[e.key]) return;\n        e.preventDefault();\n        map[e.key]();\n      }}\n      className={cn(\n        \"relative flex overflow-x-auto overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n        \"snap-x snap-mandatory scroll-px-[var(--gutter,0px)] data-dragging:snap-none data-gliding:snap-none\",\n        \"pointer-fine:cursor-grab data-dragging:cursor-grabbing data-dragging:select-none\",\n        \"rounded-xl outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        className,\n      )}\n      {...rest}\n    >\n      <div ref={track} className=\"flex shrink-0 gap-[var(--slide-gap,12px)] px-[var(--gutter,0px)] will-change-auto\" style={{ minWidth: \"100%\" }}>\n        {children}\n      </div>\n    </div>\n  );\n}\n\nexport type SnapCarouselSlideProps = React.ComponentProps<\"div\">;\n\nexport function SnapCarouselSlide({ className, children, ...rest }: SnapCarouselSlideProps) {\n  return (\n    <div\n      data-carousel-slide\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      className={cn(\"w-[var(--slide-size,100%)] min-w-0 shrink-0 snap-start snap-always\", className)}\n      {...rest}\n    >\n      {children}\n    </div>\n  );\n}\n\ntype ArrowProps = Omit<React.ComponentProps<\"button\">, \"children\"> & { size?: \"sm\" | \"md\"; children?: React.ReactNode };\n\nfunction Arrow({ towards: dir, size = \"md\", className, onClick, children, ...rest }: ArrowProps & { towards: 1 | -1 }) {\n  const { step, atStart, atEnd, viewportId } = useCarousel(dir > 0 ? \"SnapCarouselNext\" : \"SnapCarouselPrevious\");\n  const off = dir > 0 ? atEnd : atStart;\n  const Icon = dir > 0 ? ChevronRight : ChevronLeft;\n  return (\n    <button\n      type=\"button\"\n      aria-controls={viewportId}\n      aria-label={dir > 0 ? \"Next page\" : \"Previous page\"}\n      // aria-disabled, not disabled: keyboard focus stays on the button when it reaches the end.\n      aria-disabled={off || undefined}\n      data-size={size}\n      onClick={(e) => {\n        onClick?.(e);\n        if (!off && !e.defaultPrevented) step(dir, \"button\");\n      }}\n      className={cn(\n        \"group/arrow relative grid shrink-0 place-items-center rounded-full border border-line-2 bg-raised text-fg shadow-[var(--shadow)]\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color,border-color,color,opacity,scale] duration-150 ease-out active:scale-[0.92] active:duration-75\",\n        \"hover:border-fg-4 hover:bg-hover\",\n        \"aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:shadow-none aria-disabled:hover:border-line-2 aria-disabled:hover:bg-raised aria-disabled:active:scale-100\",\n        \"before:absolute before:-inset-1.5 before:content-[''] pointer-coarse:before:-inset-2.5\",\n        size === \"sm\" ? \"size-7\" : \"size-8\",\n        className,\n      )}\n      {...rest}\n    >\n      {children ?? (\n        <Icon\n          size={size === \"sm\" ? 14 : 16}\n          className={cn(\n            \"transition-transform duration-200 ease-out-expo group-aria-disabled/arrow:translate-x-0!\",\n            dir > 0 ? \"group-hover/arrow:translate-x-px\" : \"group-hover/arrow:-translate-x-px\",\n          )}\n        />\n      )}\n    </button>\n  );\n}\n\nexport type SnapCarouselPreviousProps = ArrowProps;\nexport function SnapCarouselPrevious(props: SnapCarouselPreviousProps) {\n  return <Arrow towards={-1} {...props} />;\n}\nexport type SnapCarouselNextProps = ArrowProps;\nexport function SnapCarouselNext(props: SnapCarouselNextProps) {\n  return <Arrow towards={1} {...props} />;\n}\n\nconst DOT = 6;\nconst STEP = 14;\n\nexport type SnapCarouselDotsProps = React.ComponentProps<\"div\">;\n\n/**\n * One dot per page. The indicator is tied to the scroll position itself, so\n * it travels with the finger and stretches across the gap between two pages.\n */\nexport function SnapCarouselDots({ className, ...rest }: SnapCarouselDotsProps) {\n  const { count, active, go, progress, viewportId } = useCarousel(\"SnapCarouselDots\");\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  // Between two pages the leading edge runs ahead to the next dot, then the\n  // trailing edge catches up: at f = 0.5 the pill spans both dots.\n  const frac = (p: number) => {\n    const c = Math.max(0, Math.min(count - 1, p));\n    return { k: Math.floor(c), f: c % 1 };\n  };\n  const tx = useTransform(progress, (p) => {\n    const { k, f } = frac(p);\n    return k * STEP + STEP * Math.max(0, f * 2 - 1);\n  });\n  const width = useTransform(progress, (p) => {\n    const { f } = frac(p);\n    return DOT + STEP * Math.min(1, f * 2) - STEP * Math.max(0, f * 2 - 1);\n  });\n  if (count < 2) return null;\n\n  const onKeyDown = (e: React.KeyboardEvent, i: number) => {\n    const to = e.key === \"ArrowRight\" ? i + 1 : e.key === \"ArrowLeft\" ? i - 1 : e.key === \"Home\" ? 0 : e.key === \"End\" ? count - 1 : null;\n    if (to === null) return;\n    e.preventDefault();\n    const next = Math.max(0, Math.min(count - 1, to));\n    refs.current[next]?.focus();\n    go(next, \"dot\");\n  };\n\n  return (\n    <div role=\"group\" aria-label=\"Choose page\" className={cn(\"flex items-center\", className)} {...rest}>\n      <div className=\"relative flex items-center\">\n      {Array.from({ length: count }, (_, i) => (\n        <button\n          key={i}\n          ref={(el) => {\n            refs.current[i] = el;\n          }}\n          type=\"button\"\n          tabIndex={i === active ? 0 : -1}\n          aria-label={`Page ${i + 1} of ${count}`}\n          aria-current={i === active || undefined}\n          aria-controls={viewportId}\n          onClick={() => go(i, \"dot\")}\n          onKeyDown={(e) => onKeyDown(e, i)}\n          className={cn(\n            \"group/dot relative grid h-6 place-items-center outline-none\",\n            \"before:absolute before:inset-x-0 before:-inset-y-2.5 before:content-[''] pointer-fine:before:hidden\",\n            \"focus-visible:[&>span]:outline-solid focus-visible:[&>span]:outline-1 focus-visible:[&>span]:outline-offset-2 focus-visible:[&>span]:outline-fg-3\",\n          )}\n          style={{ width: STEP }}\n        >\n          <span\n            aria-hidden\n            className=\"size-1.5 rounded-full bg-fg-4 transition-[background-color,scale] duration-150 ease-out group-hover/dot:bg-fg-3 group-active/dot:scale-75\"\n          />\n        </button>\n      ))}\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-fg\"\n        style={{ left: (STEP - DOT) / 2, x: tx, width }}\n      />\n      </div>\n    </div>\n  );\n}\n\nexport type SnapCarouselCounterProps = Omit<React.ComponentProps<\"span\">, \"children\">;\n\n/** \"3 / 8\", with the current page rolling rather than swapping. */\nexport function SnapCarouselCounter({ className, ...rest }: SnapCarouselCounterProps) {\n  const { active, count } = useCarousel(\"SnapCarouselCounter\");\n  return (\n    <span aria-hidden className={cn(\"inline-flex items-baseline gap-1 font-mono text-[11px] tabular text-fg-3\", className)} {...rest}>\n      <NumberFlow value={active + 1} className=\"text-fg\" />\n      <span className=\"text-fg-4\">/</span>\n      <span>{count}</span>\n    </span>\n  );\n}\n\n/** The carousel's state, for building your own controls inside <SnapCarousel>. */\nexport function useSnapCarousel() {\n  const { active, count, atStart, atEnd, go, step, progress, dragging } = useCarousel(\"useSnapCarousel\");\n  return {\n    page: active,\n    pageCount: count,\n    canPrevious: !atStart,\n    canNext: !atEnd,\n    dragging,\n    progress,\n    goTo: (page: number) => go(page, \"button\"),\n    next: () => step(1, \"button\"),\n    previous: () => step(-1, \"button\"),\n  };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/snap-carousel"
}
