{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toc",
  "type": "registry:ui",
  "title": "Table of Contents",
  "description": "Scroll-spy contents whose rail steps with nesting and fills as you read.",
  "categories": [
    "navigation"
  ],
  "dependencies": [
    "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/toc.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUp } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type TocItem = { id: string; title: string; level: number };\n\n/* -------------------------------------------------------------------------------------------------\n * Collect headings from the page\n * -----------------------------------------------------------------------------------------------*/\n\n/** Reads the headings inside a container, and keeps reading them as the content changes. */\nexport function useHeadings(container: React.RefObject<HTMLElement | null>, selector = \"h2[id], h3[id]\") {\n  const [items, setItems] = useState<TocItem[]>([]);\n  useEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const read = () =>\n      setItems(\n        [...el.querySelectorAll<HTMLElement>(selector)].map((h) => ({\n          id: h.id,\n          title: h.textContent?.trim() ?? \"\",\n          level: Number(h.tagName[1]) || 2,\n        })),\n      );\n    const frame = requestAnimationFrame(read);\n    const observer = new MutationObserver(read);\n    observer.observe(el, {\n      childList: true,\n      subtree: true,\n      characterData: true,\n    });\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n    };\n  }, [container, selector]);\n  return items;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Table of contents\n * -----------------------------------------------------------------------------------------------*/\n\nexport type TableOfContentsProps = Omit<React.ComponentProps<\"nav\">, \"children\"> & {\n  items: TocItem[];\n  /** The element that scrolls. Leave empty when the page itself scrolls. */\n  root?: React.RefObject<HTMLElement | null>;\n  /** How far below the top of the scroll area a heading counts as reached: your sticky header's height plus a little. */\n  offset?: number;\n  /** Write the chosen section into the URL. Replaces rather than pushes, so Back leaves the page. */\n  updateHash?: boolean;\n  /** The quiet label above the list. Pass null to hide it. */\n  label?: React.ReactNode;\n  /** A \"Back to top\" link that appears once you're a screen in. */\n  backToTop?: boolean;\n  onActiveChange?: (id: string | null) => void;\n};\n\ntype Geo = { top: number; height: number; x: number }[];\nconst JOG = 6;\n\nexport function TableOfContents({\n  items,\n  root,\n  offset = 72,\n  updateHash = true,\n  label = \"On this page\",\n  backToTop = true,\n  onActiveChange,\n  className,\n  ...rest\n}: TableOfContentsProps) {\n  const reduce = !!useReducedMotion();\n  const [active, setActive] = useState<string | null>(null);\n  const [scrolled, setScrolled] = useState(false);\n  const [geo, setGeo] = useState<Geo>([]);\n  const listRef = useRef<HTMLOListElement>(null);\n  const navRef = useRef<HTMLElement>(null);\n  const locked = useRef<string | null>(null);\n  const last = useRef<string | null>(null);\n  const unlock = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(unlock.current), []);\n  const fill = useMotionValue(0);\n  const onActive = useRef(onActiveChange);\n  useEffect(() => {\n    onActive.current = onActiveChange;\n  });\n\n  // Row positions for the rail, re-measured whenever the list changes size.\n  useEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n    const measure = () =>\n      setGeo(\n        [...list.querySelectorAll<HTMLElement>(\"[data-toc-row]\")].map((el) => ({\n          top: el.offsetTop,\n          height: el.offsetHeight,\n          x: Number(el.dataset.depth) * 10 + 0.5,\n        })),\n      );\n    const observer = new ResizeObserver(measure);\n    observer.observe(list);\n    return () => observer.disconnect();\n  }, [items]);\n\n  const path = railPath(geo);\n\n  // Scroll-spy: the active section is the last heading that has passed the offset line.\n  useEffect(() => {\n    const scroller = root?.current ?? null;\n    const target: HTMLElement | Window = scroller ?? window;\n    let frame = 0;\n    const update = () => {\n      frame = 0;\n      const top = scroller ? scroller.getBoundingClientRect().top : 0;\n      const viewport = scroller ? scroller.clientHeight : window.innerHeight;\n      const scrollTop = scroller ? scroller.scrollTop : window.scrollY;\n      const max = (scroller ? scroller.scrollHeight : document.documentElement.scrollHeight) - viewport;\n      const tops = items.map((it) => document.getElementById(it.id)?.getBoundingClientRect().top ?? Infinity).map((t) => t - top);\n      let index = -1;\n      tops.forEach((t, i) => t <= offset + 1 && (index = i));\n      // At the very bottom, the last heading that's on screen wins even if it never reached the line.\n      if (max > 0 && scrollTop >= max - 2) {\n        for (let i = tops.length - 1; i > index; i--)\n          if (tops[i] < viewport) {\n            index = i;\n            break;\n          }\n      }\n      const id = locked.current ?? (index >= 0 ? items[index].id : (items[0]?.id ?? null));\n      if (id !== last.current) {\n        last.current = id;\n        setActive(id);\n        onActive.current?.(id);\n      }\n      setScrolled(scrollTop > viewport * 0.8);\n\n      // How far through the active section you are fills the rail inside its row.\n      const i = items.findIndex((it) => it.id === id);\n      const next = tops[i + 1] ?? (max > 0 ? tops[i] + (max - scrollTop) + offset + 1 : tops[i] + 1);\n      const progress = i < 0 ? 0 : Math.min(1, Math.max(0, (offset + 1 - tops[i]) / Math.max(1, next - tops[i])));\n      fill.set(i < 0 ? 0 : i + (max > 0 && scrollTop >= max - 2 ? 1 : progress));\n    };\n    const schedule = () => {\n      if (!frame) frame = requestAnimationFrame(update);\n    };\n    // A user scroll releases a click's lock on the active item.\n    const release = () => (locked.current = null);\n    const settle = () => {\n      locked.current = null;\n      schedule();\n    };\n    target.addEventListener(\"scroll\", schedule, { passive: true });\n    target.addEventListener(\"scrollend\", settle);\n    target.addEventListener(\"wheel\", release, { passive: true });\n    target.addEventListener(\"touchstart\", release, { passive: true });\n    window.addEventListener(\"resize\", schedule);\n    schedule();\n    return () => {\n      cancelAnimationFrame(frame);\n      target.removeEventListener(\"scroll\", schedule);\n      target.removeEventListener(\"scrollend\", settle);\n      target.removeEventListener(\"wheel\", release);\n      target.removeEventListener(\"touchstart\", release);\n      window.removeEventListener(\"resize\", schedule);\n    };\n  }, [items, root, offset, fill]);\n\n  // A long contents list keeps the active entry in its own view, without moving the page.\n  useEffect(() => {\n    const nav = navRef.current;\n    const row = nav?.querySelector<HTMLElement>(`[data-toc-row][data-active]`);\n    if (!nav || !row || nav.scrollHeight <= nav.clientHeight) return;\n    const top = row.getBoundingClientRect().top - nav.getBoundingClientRect().top + nav.scrollTop;\n    if (top < nav.scrollTop + 24 || top + row.offsetHeight > nav.scrollTop + nav.clientHeight - 24)\n      nav.scrollTo({\n        top: top - nav.clientHeight / 3,\n        behavior: reduce ? \"auto\" : \"smooth\",\n      });\n  }, [active, reduce]);\n\n  const go = (id: string, e: React.MouseEvent) => {\n    const el = document.getElementById(id);\n    if (!el || e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;\n    e.preventDefault();\n    const scroller = root?.current ?? null;\n    const top = scroller ? el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop : el.getBoundingClientRect().top + window.scrollY;\n    // The indicator goes straight to the target instead of stepping through every section on the way.\n    locked.current = id;\n    last.current = id;\n    setActive(id);\n    onActive.current?.(id);\n    (scroller ?? window).scrollTo({\n      top: top - offset,\n      behavior: reduce ? \"auto\" : \"smooth\",\n    });\n    // Where scrollend isn't supported, the lock lets go on its own once the scroll has surely finished.\n    window.clearTimeout(unlock.current);\n    unlock.current = window.setTimeout(() => {\n      locked.current = null;\n      (scroller ?? window).dispatchEvent(new Event(\"scroll\"));\n    }, 1200);\n    if (updateHash) history.replaceState(history.state, \"\", `#${id}`);\n    // Keyboard users continue from the section they jumped to.\n    if (!el.hasAttribute(\"tabindex\")) el.setAttribute(\"tabindex\", \"-1\");\n    el.focus({ preventScroll: true });\n  };\n\n  const toTop = () => {\n    locked.current = null;\n    (root?.current ?? window).scrollTo({\n      top: 0,\n      behavior: reduce ? \"auto\" : \"smooth\",\n    });\n  };\n\n  const activeIndex = items.findIndex((it) => it.id === active);\n  const depthOf = (level: number) => Math.max(0, level - Math.min(...items.map((i) => i.level)));\n\n  return (\n    <nav ref={navRef} aria-label={typeof label === \"string\" ? label : \"On this page\"} className={cn(\"relative flex min-h-0 flex-col text-[12.5px]\", className)} {...rest}>\n      {label !== null && <p className=\"mb-2.5 font-mono text-2xs tracking-[0.08em] text-fg-3 uppercase\">{label}</p>}\n      <div className=\"relative\">\n        <Rail path={path} geo={geo} activeIndex={activeIndex} fill={fill} reduce={reduce} />\n        <ol ref={listRef} className=\"relative flex flex-col\">\n          {items.map((item, i) => {\n            const on = item.id === active;\n            const depth = depthOf(item.level);\n            return (\n              <li key={item.id} data-toc-row data-depth={depth} data-active={on || undefined} className=\"flex\">\n                <a\n                  href={`#${item.id}`}\n                  aria-current={on ? \"location\" : undefined}\n                  onClick={(e) => go(item.id, e)}\n                  style={{ paddingLeft: 12 + depth * 10 }}\n                  className={cn(\n                    \"relative block w-full rounded-md py-[5px] pr-2 leading-[18px] outline-none\",\n                    \"transition-[color] duration-150 ease-out\",\n                    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n                    on ? \"text-fg\" : i < activeIndex ? \"text-fg-3 hover:text-fg-2\" : \"text-fg-3 hover:text-fg-2\",\n                    depth > 0 && \"text-[12px]\",\n                  )}\n                >\n                  {item.title}\n                </a>\n              </li>\n            );\n          })}\n        </ol>\n      </div>\n      {backToTop && (\n        <AnimatePresence initial={false}>\n          {scrolled && (\n            <motion.button\n              type=\"button\"\n              onClick={toTop}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.12 } }}\n              transition={{ duration: 0.2, ease: ease.out }}\n              className={cn(\n                \"group/top mt-3 flex h-7 w-fit items-center gap-1.5 rounded-md pr-2 pl-3 text-[12px] text-fg-3 outline-none\",\n                \"transition-[color,scale] duration-150 hover:text-fg active:scale-[0.97]\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n              )}\n            >\n              Back to top\n              <ArrowUp size={12} className=\"transition-transform duration-150 ease-out group-hover/top:-translate-y-px motion-reduce:transition-none\" />\n            </motion.button>\n          )}\n        </AnimatePresence>\n      )}\n    </nav>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The rail: a line that steps in for nested sections, a fill for what you've read, and a\n * thumb that slides along the same path to the section you're in.\n * -----------------------------------------------------------------------------------------------*/\n\ntype Path = {\n  d: string;\n  starts: number[];\n  ends: number[];\n  total: number;\n  height: number;\n};\n\nfunction railPath(geo: Geo): Path {\n  let d = \"\";\n  let len = 0;\n  const starts: number[] = [];\n  const ends: number[] = [];\n  geo.forEach((row, i) => {\n    const prev = geo[i - 1];\n    const bottom = row.top + row.height;\n    if (!prev) {\n      d += `M${row.x} ${row.top}`;\n      starts.push(0);\n    } else if (prev.x !== row.x) {\n      // Step in or out over the first few pixels of the row.\n      d += `L${row.x} ${row.top + JOG}`;\n      len += Math.hypot(row.x - prev.x, JOG);\n      starts.push(len);\n    } else starts.push(len);\n    const from = prev && prev.x !== row.x ? row.top + JOG : row.top;\n    d += `L${row.x} ${bottom}`;\n    len += bottom - from;\n    ends.push(len);\n  });\n  const last = geo[geo.length - 1];\n  return {\n    d,\n    starts,\n    ends,\n    total: len,\n    height: last ? last.top + last.height : 0,\n  };\n}\n\nfunction Rail({ path, geo, activeIndex, fill, reduce }: { path: Path; geo: Geo; activeIndex: number; fill: MotionValue<number>; reduce: boolean }) {\n  const start = useMotionValue(0);\n  const length = useMotionValue(0);\n  const shown = useRef(false);\n\n  useEffect(() => {\n    if (activeIndex < 0 || !path.total) return;\n    const s = path.starts[activeIndex] + 3;\n    const l = Math.max(4, path.ends[activeIndex] - path.starts[activeIndex] - 6);\n    if (!shown.current || reduce) {\n      start.jump(s);\n      length.jump(l);\n      shown.current = true;\n      return;\n    }\n    const a = animate(start, s, spring.snappy);\n    const b = animate(length, l, spring.snappy);\n    return () => {\n      a.stop();\n      b.stop();\n    };\n  }, [activeIndex, path.total, path.starts, path.ends, start, length, reduce]);\n\n  // Fill is \"rows read\" as a fractional index; map it onto the path's length.\n  const filled = useTransform(fill, (f) => {\n    if (!path.total) return 0;\n    const i = Math.min(Math.floor(f), geo.length - 1);\n    if (i < 0) return 0;\n    return path.starts[i] + (path.ends[i] - path.starts[i]) * Math.min(1, f - i);\n  });\n  const fillDash = useTransform(filled, (v) => `${v} ${path.total + 10}`);\n  const thumbDash = useTransform(length, (v) => `${v} ${path.total + 10}`);\n  const thumbOffset = useTransform(start, (v) => -v);\n\n  if (!path.total) return null;\n  return (\n    <svg aria-hidden className=\"pointer-events-none absolute top-0 left-0 overflow-visible\" width={24} height={path.height} fill=\"none\">\n      <path d={path.d} className=\"stroke-line-2\" strokeWidth={1} />\n      <motion.path d={path.d} className=\"stroke-fg-4\" strokeWidth={1} style={{ strokeDasharray: fillDash }} />\n      <motion.path d={path.d} className=\"stroke-fg\" strokeWidth={1.5} strokeLinecap=\"round\" style={{ strokeDasharray: thumbDash, strokeDashoffset: thumbOffset }} />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/toc"
}
