{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "page-dots",
  "type": "registry:ui",
  "title": "Page Dots",
  "description": "Carousel dots whose active pill stretches, fills with autoplay time and scrolls when long.",
  "categories": [
    "navigation"
  ],
  "dependencies": [
    "@base-ui/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",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/page-dots.tsx",
      "content": "\"use client\";\nimport { Tabs as BaseTabs } from \"@base-ui/react/tabs\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Pause, Play } from \"@/lib/icons\";\nimport { spring, swap } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Size = \"sm\" | \"md\";\n// dot: resting diameter · pill: active width · gap: space between dots. Each button owns half the gap on either side.\nconst metrics: Record<Size, { dot: number; pill: number; gap: number }> = {\n  sm: { dot: 5, pill: 16, gap: 6 },\n  md: { dot: 6, pill: 22, gap: 8 },\n};\n\n/** Which run of dots is visible when there are more pages than fit. Moves only when the active dot reaches an edge. */\nfunction nextWindow(start: number, active: number, count: number, max: number) {\n  if (count <= max) return 0;\n  const last = count - max;\n  // Keep one dot of lookahead on each side while there is more beyond it.\n  if (active <= start) start = active - 1;\n  else if (active >= start + max - 1) start = active - max + 2;\n  return Math.min(Math.max(start, 0), last);\n}\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\n/** Reduced motion as hydration-safe state: false on the server and during hydration, the real answer right after. */\nfunction usePrefersReducedMotion() {\n  return useSyncExternalStore(\n    (onChange) => {\n      const mq = window.matchMedia(reducedQuery);\n      mq.addEventListener(\"change\", onChange);\n      return () => mq.removeEventListener(\"change\", onChange);\n    },\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n}\n\nexport type PageDotsProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"dir\"> & {\n  /** How many pages there are. */\n  count: number;\n  /** The current page, zero-based. */\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (index: number) => void;\n  /** Advance on its own every this many milliseconds, with the time drawn into the active pill. */\n  autoplay?: number;\n  /** Hold autoplay, for instance while the user drags a slide. Hover, focus, a hidden tab and going off screen hold it too. */\n  paused?: boolean;\n  /** Show a pause / play button after the dots when autoplay is on. */\n  showToggle?: boolean;\n  /** At most this many dots show; the rest shrink away at the edges and slide in as you go. */\n  max?: number;\n  size?: Size;\n  /** Plain dots, or dots on a small frosted chip for sitting over images. */\n  variant?: \"plain\" | \"contained\";\n  /** Names the group (\"Slides\", \"Photos\"). */\n  \"aria-label\"?: string;\n  /** The accessible name of each dot. */\n  getLabel?: (index: number, count: number) => string;\n};\n\nexport function PageDots({\n  count,\n  value: valueProp,\n  defaultValue = 0,\n  onValueChange,\n  autoplay,\n  paused: pausedProp = false,\n  showToggle = true,\n  max = 7,\n  size = \"md\",\n  variant = \"plain\",\n  \"aria-label\": label = \"Pages\",\n  getLabel = (i, n) => `Page ${i + 1} of ${n}`,\n  className,\n  ...rest\n}: PageDotsProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const reduce = useReducedMotion();\n  const m = metrics[size];\n  const slot = m.dot + m.gap;\n  const visible = Math.min(count, max);\n\n  // The visible window follows the active dot (state adjusted during render, not in an effect).\n  const [start, setStart] = useState(() => nextWindow(0, value, count, max));\n  const nextStart = nextWindow(start, value, count, max);\n  if (nextStart !== start) setStart(nextStart);\n\n  // Autoplay: one progress value, drawn into the active pill, that advances the page when it fills.\n  const root = useRef<HTMLDivElement>(null);\n  const progress = useMotionValue(0);\n  const controls = useRef<AnimationPlaybackControls | null>(null);\n  const advance = useRef(() => {});\n  useEffect(() => {\n    advance.current = () => setValue((value + 1) % count);\n  });\n\n  const [userPaused, setUserPaused] = useState<boolean | null>(null);\n  const [held, setHeld] = useState({ hover: false, focus: false, hidden: false, offscreen: false });\n  // Reduced motion starts held: nothing moves on its own until the person presses play.\n  const prefersReduced = usePrefersReducedMotion();\n  const stopped = userPaused ?? prefersReduced;\n  const paused = pausedProp || stopped || held.hover || held.focus || held.hidden || held.offscreen;\n\n  useEffect(() => {\n    if (!autoplay) return;\n    progress.jump(0);\n    const c = animate(progress, 1, { duration: autoplay / 1000, ease: \"linear\", onComplete: () => advance.current() });\n    controls.current = c;\n    return () => {\n      c.stop();\n      controls.current = null;\n    };\n  }, [autoplay, value, progress]);\n\n  useEffect(() => {\n    if (paused) controls.current?.pause();\n    else controls.current?.play();\n  }, [paused, value, autoplay]);\n\n  useEffect(() => {\n    if (!autoplay) return;\n    const el = root.current;\n    const onVisibility = () => setHeld((h) => ({ ...h, hidden: document.hidden }));\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    const io = new IntersectionObserver(([entry]) => setHeld((h) => ({ ...h, offscreen: !entry.isIntersecting })));\n    if (el) io.observe(el);\n    return () => {\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      io.disconnect();\n    };\n  }, [autoplay]);\n\n  const width = count > max ? (visible - 1) * slot + m.pill + m.gap : undefined;\n\n  return (\n    <div\n      ref={root}\n      data-variant={variant}\n      data-size={size}\n      onPointerEnter={(e) => e.pointerType === \"mouse\" && setHeld((h) => ({ ...h, hover: true }))}\n      onPointerLeave={() => setHeld((h) => ({ ...h, hover: false }))}\n      className={cn(\n        \"group/dots inline-flex items-center\",\n        variant === \"contained\" && \"rounded-full border border-line bg-frame/75 px-1 shadow-[var(--shadow)] backdrop-blur-md\",\n        className,\n      )}\n      {...rest}\n    >\n      <BaseTabs.Root\n        value={value}\n        onValueChange={(v) => setValue(v as number)}\n        onKeyDownCapture={(e) => (e.currentTarget.dataset.nav = \"key\")}\n        onPointerDownCapture={(e) => (e.currentTarget.dataset.nav = \"pointer\")}\n        onFocusCapture={() => setHeld((h) => ({ ...h, focus: true }))}\n        onBlurCapture={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setHeld((h) => ({ ...h, focus: false }))}\n        className=\"group/root\"\n      >\n        {/* The viewport clips the run of dots past max. overflow: clip, not hidden, so focusing a far dot can never scroll it. */}\n        <div className=\"overflow-clip pointer-coarse:-my-2.5 pointer-coarse:py-2.5\" style={{ width }}>\n          <BaseTabs.List\n            aria-label={label}\n            activateOnFocus\n            className=\"flex transition-[translate] duration-300 ease-in-out-quart group-data-[nav=key]/root:duration-0\"\n            style={{ translate: `${-start * slot}px 0` }}\n          >\n            {Array.from({ length: count }, (_, i) => {\n              const active = i === value;\n              const moreLeft = start > 0;\n              const moreRight = start + max < count;\n              // Dots at a window edge with more beyond them shrink, hinting that the row continues.\n              const edge = active\n                ? 1\n                : count > max && ((moreLeft && i === start) || (moreRight && i === start + max - 1))\n                  ? 0.45\n                  : count > max && ((moreLeft && i === start + 1) || (moreRight && i === start + max - 2))\n                    ? 0.72\n                    : i < start || i >= start + max\n                      ? 0\n                      : 1;\n              return (\n                <BaseTabs.Tab\n                  key={i}\n                  value={i}\n                  aria-label={getLabel(i, count)}\n                  className={cn(\n                    \"group/dot relative grid shrink-0 place-items-center outline-none\",\n                    \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n                    size === \"sm\" ? \"h-5\" : \"h-6\",\n                    // Taller to the finger than to the eye.\n                    \"pointer-coarse:before:absolute pointer-coarse:before:inset-x-0 pointer-coarse:before:-inset-y-2.5 pointer-coarse:before:content-['']\",\n                  )}\n                  style={{ paddingInline: m.gap / 2 }}\n                >\n                  <span\n                    className={cn(\n                      \"relative block overflow-hidden rounded-full\",\n                      \"transition-[width,scale,background-color] duration-300 ease-in-out-quart group-data-[nav=key]/root:duration-0\",\n                      \"group-active/dot:scale-[0.8] group-active/dot:duration-100\",\n                      \"group-focus-visible/dot:outline-solid group-focus-visible/dot:outline-1 group-focus-visible/dot:outline-offset-2 group-focus-visible/dot:outline-fg-3\",\n                      active ? (autoplay ? \"bg-fg/25\" : \"bg-fg\") : \"bg-fg-4 group-hover/dot:bg-fg-3\",\n                    )}\n                    style={{ width: active ? m.pill : m.dot, height: m.dot, scale: edge }}\n                  >\n                    {active && autoplay ? (\n                      <motion.span aria-hidden className=\"absolute inset-0 origin-left rounded-full bg-fg\" style={{ scaleX: progress }} />\n                    ) : null}\n                  </span>\n                </BaseTabs.Tab>\n              );\n            })}\n          </BaseTabs.List>\n        </div>\n      </BaseTabs.Root>\n\n      {autoplay && showToggle && (\n        <button\n          type=\"button\"\n          aria-label={stopped ? \"Play slideshow\" : \"Pause slideshow\"}\n          onClick={() => setUserPaused(!stopped)}\n          className={cn(\n            \"relative ml-1 grid size-6 shrink-0 place-items-center rounded-full text-fg-3 outline-none\",\n            \"transition-[background-color,color,scale] duration-150 ease-out-quart hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n            \"pointer-coarse:before:absolute pointer-coarse:before:-inset-2.5 pointer-coarse:before:content-['']\",\n          )}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={stopped ? \"play\" : \"pause\"}\n              className=\"grid place-items-center\"\n              initial={reduce ? { opacity: 0 } : swap.initial}\n              animate={swap.animate}\n              exit={reduce ? { opacity: 0 } : swap.exit}\n              transition={reduce ? { duration: 0.12 } : spring.pop}\n            >\n              {stopped ? <Play size={12} className=\"translate-x-px\" /> : <Pause size={12} />}\n            </motion.span>\n          </AnimatePresence>\n        </button>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/page-dots"
}
