{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scrollspy-toc",
  "type": "registry:ui",
  "title": "Scrollspy TOC",
  "description": "A table of contents that tracks the section you are reading with a sliding indicator and per-section progress.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/scrollspy-toc.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nexport interface ScrollspyTocItem {\n  /** id of the section element this entry links to. */\n  id: string;\n  label: string;\n  /** Heading level (2 = top level, 3 = nested…). Deeper levels are indented. */\n  level?: number;\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface ScrollspyTocProps extends Omit<React.HTMLAttributes<HTMLElement>, \"title\"> {\n  items: ScrollspyTocItem[];\n  /** Scrollable element that contains the sections. Defaults to the window. */\n  container?: React.RefObject<HTMLElement | null>;\n  /** Distance from the top of the scroll area where a section becomes active, in px (e.g. a sticky header's height). */\n  offset?: number;\n  /** Active marker: a line on the rail, or a pill behind the label. */\n  indicator?: \"line\" | \"pill\";\n  /** Fill each entry's rail segment as its section is read. */\n  showProgress?: boolean;\n  /** Smooth-scroll on click (instant with prefers-reduced-motion). */\n  smooth?: boolean;\n  /** Visible heading above the list. Empty string hides it. */\n  title?: string;\n  /** Accessible name of the navigation landmark. */\n  label?: string;\n  /** Spring for the indicator as it slides between entries. */\n  spring?: Spring;\n  /** Accent for the indicator, progress fill and focus ring. */\n  accent?: string;\n  /** Called when the active section changes. */\n  onActiveChange?: (id: string) => void;\n}\n\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n));\n\n/**\n * A table of contents that follows the reader: IntersectionObserver picks the active section,\n * a shared-layout indicator slides and resizes between entries, and each entry can show how much\n * of its section has been read. Works with the window or any scrollable container.\n */\nexport function ScrollspyToc({\n  items,\n  container,\n  offset = 24,\n  indicator = \"line\",\n  showProgress = true,\n  smooth = true,\n  title = \"On this page\",\n  label = \"Table of contents\",\n  spring = { stiffness: 380, damping: 34, mass: 0.8 },\n  accent = \"#FF7448\",\n  onActiveChange,\n  className = \"\",\n  style,\n  ...props\n}: ScrollspyTocProps) {\n  const uid = React.useId();\n  const reduceMotion = useReducedMotion();\n  const [activeState, setActiveState] = React.useState<string | null>(null);\n  const activeId = activeState && items.some((item) => item.id === activeState) ? activeState : (items[0]?.id ?? null);\n  const linkRefs = React.useRef(new Map<string, HTMLAnchorElement>());\n  /** While a click-scroll runs, the clicked entry stays active instead of flickering through others. */\n  const lockRef = React.useRef<string | null>(null);\n  const baseLevel = Math.min(...items.map((item) => item.level ?? 2));\n\n  const setActive = (id: string) => {\n    if (id === activeId) return;\n    setActiveState(id);\n    onActiveChange?.(id);\n  };\n  const setActiveFromEffect = React.useEffectEvent(setActive);\n\n  // Keyed on the ids (not the array identity) so inline item arrays do not re-subscribe every render.\n  const idsKey = items.map((item) => item.id).join(\" \");\n\n  React.useEffect(() => {\n    const root = container?.current ?? null;\n    const scroller: HTMLElement | Window = root ?? window;\n    const sections = idsKey\n      .split(\" \")\n      .map((id) => (id ? document.getElementById(id) : null))\n      .filter((el): el is HTMLElement => el !== null);\n    if (!sections.length) return;\n    const visible = new Set<string>();\n\n    const metrics = () => {\n      if (root) return { top: root.getBoundingClientRect().top, scrollTop: root.scrollTop, height: root.clientHeight, scrollHeight: root.scrollHeight };\n      const doc = document.documentElement;\n      return { top: 0, scrollTop: window.scrollY, height: window.innerHeight, scrollHeight: doc.scrollHeight };\n    };\n\n    let frame = 0;\n    const update = () => {\n      frame = 0;\n      const m = metrics();\n      const atBottom = m.scrollTop + m.height >= m.scrollHeight - 2;\n      if (!lockRef.current) {\n        // At the very end, short trailing sections can never reach the activation line.\n        const next = atBottom ? sections[sections.length - 1].id : sections.find((s) => visible.has(s.id))?.id;\n        if (next) setActiveFromEffect(next);\n      }\n      if (!showProgress) return;\n      const line = m.top + offset;\n      sections.forEach((section, i) => {\n        const start = section.getBoundingClientRect().top;\n        const end = i < sections.length - 1 ? sections[i + 1].getBoundingClientRect().top : section.getBoundingClientRect().bottom;\n        const p = atBottom ? 1 : clamp01((line - start) / Math.max(1, end - start));\n        linkRefs.current.get(section.id)?.style.setProperty(\"--sp-progress\", p.toFixed(3));\n      });\n    };\n    const schedule = () => {\n      if (!frame) frame = requestAnimationFrame(update);\n    };\n\n    // A band from the activation line down to 40% of the viewport: the first section inside it is active.\n    const observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          if (entry.isIntersecting) visible.add(entry.target.id);\n          else visible.delete(entry.target.id);\n        }\n        schedule();\n      },\n      { root, rootMargin: `-${offset}px 0px -60% 0px`, threshold: 0 },\n    );\n    sections.forEach((section) => observer.observe(section));\n    scroller.addEventListener(\"scroll\", schedule, { passive: true });\n    window.addEventListener(\"resize\", schedule);\n    schedule();\n\n    return () => {\n      observer.disconnect();\n      scroller.removeEventListener(\"scroll\", schedule);\n      window.removeEventListener(\"resize\", schedule);\n      cancelAnimationFrame(frame);\n    };\n  }, [idsKey, container, offset, showProgress]);\n\n  const handleClick = (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {\n    const target = document.getElementById(id);\n    if (!target || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;\n    event.preventDefault();\n    const root = container?.current ?? null;\n    const behavior: ScrollBehavior = smooth && !reduceMotion ? \"smooth\" : \"auto\";\n    // Land 2px past the activation line so the observer agrees with the click.\n    if (root) {\n      const top = target.getBoundingClientRect().top - root.getBoundingClientRect().top + root.scrollTop - offset + 2;\n      root.scrollTo({ top, behavior });\n    } else {\n      window.scrollTo({ top: target.getBoundingClientRect().top + window.scrollY - offset + 2, behavior });\n    }\n\n    lockRef.current = id;\n    setActive(id);\n    const scroller: HTMLElement | Window = root ?? window;\n    let timer = 0;\n    const release = () => {\n      lockRef.current = null;\n      window.clearTimeout(timer);\n      scroller.removeEventListener(\"scrollend\", release);\n    };\n    scroller.addEventListener(\"scrollend\", release, { once: true });\n    // Fallback when the target is already in place (no scroll) or scrollend is unsupported.\n    timer = window.setTimeout(release, behavior === \"smooth\" ? 1200 : 120);\n\n    // Move focus to the section for keyboard and screen reader users, without a second scroll.\n    if (!target.hasAttribute(\"tabindex\")) target.setAttribute(\"tabindex\", \"-1\");\n    target.focus({ preventScroll: true });\n  };\n\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n\n  return (\n    <nav aria-label={label} className={`text-sm ${className}`} style={{ [\"--sp-accent\" as string]: accent, ...style }} {...props}>\n      {title && (\n        <p aria-hidden=\"true\" className=\"mb-3 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n          {title}\n        </p>\n      )}\n      <ol className=\"relative before:absolute before:inset-y-0 before:left-0 before:w-px before:bg-zinc-900/10 dark:before:bg-white/10\">\n        {items.map((item) => {\n          const isActive = item.id === activeId;\n          const indent = Math.max(0, (item.level ?? 2) - baseLevel) * 12;\n          return (\n            <li key={item.id} className=\"relative\">\n              <a\n                ref={(node) => {\n                  if (node) linkRefs.current.set(item.id, node);\n                  else linkRefs.current.delete(item.id);\n                }}\n                href={`#${item.id}`}\n                aria-current={isActive ? \"location\" : undefined}\n                onClick={(event) => handleClick(event, item.id)}\n                className={`relative block rounded-[6px] py-1.5 pr-2 leading-snug tracking-tight outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--sp-accent)] ${\n                  indent ? \"text-[13px]\" : \"\"\n                } ${\n                  isActive\n                    ? \"font-medium text-zinc-950 dark:text-white\"\n                    : \"text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100\"\n                }`}\n                style={{ paddingLeft: 14 + indent, [\"--sp-progress\" as string]: 0 }}\n              >\n                {showProgress && (\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"absolute inset-y-0 left-0 w-[2px] -translate-x-[0.5px] origin-top scale-y-[var(--sp-progress)] bg-[color-mix(in_oklab,var(--sp-accent)_45%,transparent)]\"\n                  />\n                )}\n                {isActive && (\n                  <motion.span\n                    aria-hidden=\"true\"\n                    // useId scopes the layoutId, so several tables of contents never share an indicator.\n                    layoutId={`${uid}-indicator`}\n                    transition={transition}\n                    className={\n                      indicator === \"line\"\n                        ? \"absolute inset-y-0 left-0 w-[2px] -translate-x-[0.5px] rounded-full bg-[var(--sp-accent)]\"\n                        : \"absolute inset-y-0 right-0 rounded-[6px] bg-zinc-900/[0.06] dark:bg-white/[0.07]\"\n                    }\n                    style={indicator === \"pill\" ? { left: 6 + indent } : undefined}\n                  />\n                )}\n                <span className=\"relative\">{item.label}</span>\n              </a>\n            </li>\n          );\n        })}\n      </ol>\n    </nav>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/scrollspy-toc"
}
