{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toc",
  "title": "Table of Contents",
  "description": "A table of contents for the page the reader is on, with the section they are currently reading highlighted as they scroll. Use it for the \"On this page\" rail beside documentation and guides, API references, changelogs and release notes, long blog posts and tutorials, handbooks, legal and policy pages, and reports. Common asks it answers: \"table of contents component\", \"toc sidebar\", \"on this page nav\", \"scrollspy\", \"scroll spy in React\", \"highlight the active heading while scrolling\", \"docs right rail\", \"anchor link navigation\", \"in-page navigation\", \"sticky table of contents\", \"MDX toc\", \"react-scrollspy alternative\". shadcn/ui ships nothing for this, and its navigation-menu and sidebar are for moving between pages, not around one. You pass the headings in as items — the shape rehype-slug, MDX and Contentlayer pipelines already hand you — so the list is rendered on the server and the links work before, and without, JavaScript; only the highlight needs the client. The awkward part is deciding which heading counts as current, and this fixes the three ways a hand-rolled one gets it wrong. The last section is normally shorter than the viewport, so its heading never reaches the activation line and the final entry can never light up — reaching the bottom of the scrollable area selects the last heading, because there is nothing further to read. A section stays current while it is being read rather than only while its heading is on screen, which is where an IntersectionObserver checking is-it-visible goes blank on any section taller than the window. And clicking an entry starts a scroll lasting hundreds of milliseconds, during which every heading it travels past would light up in turn, leaving the entry you clicked as the one thing not highlighted; the list holds your choice until the scroll settles, and hands control straight back if you grab the page mid-flight. offset clears a sticky site header, both for where a click lands and for where the current section begins, since the browser's own fragment jump puts the heading underneath it. It re-measures on resize and once web fonts have loaded, follows a nested scroller when the app shell scrolls an inner element instead of the window, and honours prefers-reduced-motion. The active entry is marked with aria-current=\"location\" rather than colour alone, so it is announced and not merely seen; because clicking has to preventDefault to apply the offset, focus is moved to the heading the way the browser would have, so a keyboard reader lands in the section instead of carrying on down the contents. Modifier and middle clicks are left alone, so opening a section in a new tab still works.",
  "files": [
    {
      "path": "registry/ui/toc.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ninterface TocItem {\n  /** The heading's `id` in the document — the anchor target, written without the \"#\". */\n  id: string\n  /** Text shown in the list. */\n  title: string\n  /** Heading depth (2 for `h2`, 3 for `h3` …). Only the depth *relative* to the shallowest\n   *  item matters, so a page whose headings start at `h3` is not indented as a whole. */\n  level?: number\n}\n\ninterface TocProps extends Omit<React.ComponentPropsWithoutRef<\"nav\">, \"children\"> {\n  /** Headings in document order. Most MDX pipelines already hand you exactly this shape. */\n  items: TocItem[]\n  /** Height of a sticky site header, in pixels. Sets the line at which a section becomes the\n   *  current one, and how far above the heading a click lands. Defaults to 0. */\n  offset?: number\n  /** Accessible name for the nav landmark. Defaults to \"On this page\". */\n  label?: string\n}\n\n// useLayoutEffect resolves the highlight before paint, so the list never flashes with the\n// wrong entry marked; it warns during SSR, so fall back to useEffect on the server.\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\n/** Indent steps are capped so an `h6` under an `h2` cannot push its label out of a sidebar. */\nconst MAX_INDENT_STEPS = 3\nconst INDENT_STEP_PX = 12\n\n/** How long the list stops following the page after a click. See `lockedRef` below. */\nconst SETTLE_MS = 120\n\nfunction normalizeLevel(level: number | undefined): number {\n  const n = Number(level)\n  return Number.isFinite(n) ? n : 2\n}\n\n/**\n * The scrollable ancestor that actually moves, or null when that is the page itself.\n *\n * An app shell that scrolls an inner `<main>` rather than the window is common enough that\n * assuming the window would leave the highlight frozen in exactly those layouts.\n */\nfunction findScroller(el: Element | null): Element | null {\n  for (let node = el?.parentElement ?? null; node; node = node.parentElement) {\n    const overflowY = getComputedStyle(node).overflowY\n    if (\n      (overflowY === \"auto\" || overflowY === \"scroll\") &&\n      node.scrollHeight > node.clientHeight\n    ) {\n      return node\n    }\n  }\n  return null\n}\n\n/**\n * Whether the reader has hit the end of the scrollable area.\n *\n * This is the fix for the oldest bug in hand-rolled scrollspies: the final section is usually\n * shorter than the viewport, so its heading never reaches the activation line and the last\n * entry can never light up no matter how far you scroll. At the bottom there is nothing left\n * to scroll to, so the last heading is by definition the one being read.\n *\n * Content that does not scroll at all is not \"at the bottom\" — every heading is on screen and\n * the reader is at the top, so the ordinary rule gives the better answer.\n */\nfunction isAtBottom(scroller: Element | null): boolean {\n  const el = scroller ?? document.scrollingElement ?? document.documentElement\n  const furthest = el.scrollHeight - el.clientHeight\n  if (furthest <= 0) return false\n  return furthest - el.scrollTop <= 2\n}\n\n/**\n * Index of the entry to mark, given each heading's distance from the top of the viewport.\n * A `null` top means the heading is not in the document — those entries are skipped rather\n * than shifting every index after them.\n *\n * The last heading at or above the line wins, so a section stays current for as long as it is\n * being read — including after its own heading has scrolled off the top, which is exactly\n * when a naive \"is the heading visible?\" test goes blank. The 1px tolerance absorbs sub-pixel\n * layout, which otherwise reports a heading resting exactly on the line as being below it.\n */\nfunction pickActive(\n  tops: Array<number | null>,\n  line: number,\n  atBottom: boolean\n): number {\n  let first = -1\n  let last = -1\n  let active = -1\n  for (let index = 0; index < tops.length; index++) {\n    const top = tops[index]\n    if (top === null) continue\n    if (first === -1) first = index\n    last = index\n    if (top - line <= 1) active = index\n  }\n\n  if (first === -1) return -1\n  if (atBottom) return last\n  // Above the first heading — in a page's intro, before any section has started. Marking the\n  // first entry beats marking none, which reads as a list that has stopped working.\n  return active === -1 ? first : active\n}\n\n/**\n * A table of contents for the page being read, with the current section highlighted as the\n * reader scrolls. Use it for the \"On this page\" rail beside docs, guides, changelogs, long\n * blog posts, API references, legal pages and reports.\n *\n * The list is plain anchors rendered from `items`, so it is server-rendered and works before\n * — and without — JavaScript; the highlight is the only part that needs the client.\n */\nexport const Toc = React.forwardRef<HTMLElement, TocProps>(function Toc(\n  { className, items, offset = 0, label = \"On this page\", ...props },\n  forwardedRef\n) {\n  // -1 until measured. The server cannot know the scroll position and the first client render\n  // has to match what it sent, so the highlight is applied by the layout effect below —\n  // before paint, so it is never visibly absent.\n  const [activeIndex, setActiveIndex] = React.useState(-1)\n\n  // While set, the list shows this id and stops following the page. A click starts a scroll\n  // that can take hundreds of milliseconds, and every heading it travels past would otherwise\n  // light up in turn — leaving the entry that was actually clicked as the one thing not\n  // highlighted while the animation runs.\n  const lockedRef = React.useRef<string | null>(null)\n  const settleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n  const frameRef = React.useRef<number | null>(null)\n\n  // Walking the ancestors with getComputedStyle is too much to redo on every frame of a\n  // scroll, so the answer is kept until the heading it was derived from is replaced.\n  const scrollerRef = React.useRef<Element | null>(null)\n  const scrollerSourceRef = React.useRef<Element | null>(null)\n\n  // Serialised rather than joined into one string: ids are compared whole, so an id that\n  // happens to contain the separator cannot smear two entries into one.\n  const idsKey = JSON.stringify(items.map((item) => item.id))\n\n  // `items` is captured from the render that last changed the ids — the only field measuring\n  // reads — so a fresh array carrying the same ids does not need to rebuild this.\n  const measure = React.useCallback(() => {\n    const elements = items.map((item) => document.getElementById(item.id))\n\n    const locked = lockedRef.current\n    if (locked !== null) {\n      const lockedIndex = items.findIndex((item) => item.id === locked)\n      if (lockedIndex !== -1) {\n        setActiveIndex(lockedIndex)\n        return\n      }\n      lockedRef.current = null\n    }\n\n    const firstPresent = elements.find((el): el is HTMLElement => el !== null) ?? null\n    if (firstPresent !== scrollerSourceRef.current) {\n      scrollerSourceRef.current = firstPresent\n      scrollerRef.current = findScroller(firstPresent)\n    }\n\n    // Distances are taken from the top of whatever is scrolling. For the page that is the\n    // viewport; for a nested scroller it is that box, which may sit well down the screen —\n    // measuring those headings against the viewport instead would hold the highlight back by\n    // however far the box starts below the top of the window.\n    const scroller = scrollerRef.current\n    const lineTop = scroller ? scroller.getBoundingClientRect().top : 0\n    const tops = elements.map((el) =>\n      el ? el.getBoundingClientRect().top - lineTop : null\n    )\n    setActiveIndex(pickActive(tops, offset, isAtBottom(scroller)))\n    // idsKey stands in for items: only the ids are read above.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [idsKey, offset])\n\n  useIsomorphicLayoutEffect(measure, [measure])\n\n  React.useEffect(() => {\n    const schedule = () => {\n      if (frameRef.current !== null) return\n      frameRef.current = requestAnimationFrame(() => {\n        frameRef.current = null\n        measure()\n      })\n    }\n\n    const onScroll = () => {\n      if (lockedRef.current !== null) {\n        // Wait for the scroll to stop before following the page again. Each event pushes the\n        // deadline out, so the lock outlives a smooth scroll of any length.\n        if (settleTimerRef.current) clearTimeout(settleTimerRef.current)\n        settleTimerRef.current = setTimeout(() => {\n          lockedRef.current = null\n          measure()\n        }, SETTLE_MS)\n      }\n      schedule()\n    }\n\n    // A scroll event does not bubble, but it does travel down the capture path, so a single\n    // listener on the window covers the page and any nested scroller inside it.\n    window.addEventListener(\"scroll\", onScroll, true)\n    window.addEventListener(\"resize\", schedule)\n    return () => {\n      window.removeEventListener(\"scroll\", onScroll, true)\n      window.removeEventListener(\"resize\", schedule)\n      if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)\n      if (settleTimerRef.current) clearTimeout(settleTimerRef.current)\n    }\n  }, [measure])\n\n  // Taking the page over mid-animation should hand control straight back, rather than leave\n  // the reader scrolling with the list still pinned to whatever they last clicked.\n  React.useEffect(() => {\n    const release = () => {\n      if (lockedRef.current === null) return\n      lockedRef.current = null\n      if (settleTimerRef.current) clearTimeout(settleTimerRef.current)\n      measure()\n    }\n    const options = { capture: true, passive: true } as const\n    window.addEventListener(\"wheel\", release, options)\n    window.addEventListener(\"touchstart\", release, options)\n    return () => {\n      window.removeEventListener(\"wheel\", release, options)\n      window.removeEventListener(\"touchstart\", release, options)\n    }\n  }, [measure])\n\n  // Headings move when an image above them loads or a web font swaps in, neither of which\n  // fires a scroll or a resize.\n  React.useEffect(() => {\n    if (typeof document === \"undefined\" || !document.fonts) return\n    let cancelled = false\n    document.fonts.ready.then(() => {\n      if (!cancelled) measure()\n    })\n    return () => {\n      cancelled = true\n    }\n  }, [measure])\n\n  function handleClick(event: React.MouseEvent<HTMLAnchorElement>, id: string) {\n    // Leave anything but a plain left click alone, so opening a section in a new tab or\n    // window keeps working — the href is a real one.\n    if (\n      event.defaultPrevented ||\n      event.button !== 0 ||\n      event.metaKey ||\n      event.ctrlKey ||\n      event.shiftKey ||\n      event.altKey\n    ) {\n      return\n    }\n\n    const target = document.getElementById(id)\n    // Let the browser attempt the fragment itself rather than swallow the click.\n    if (!target) return\n    event.preventDefault()\n\n    lockedRef.current = id\n\n    const scroller = findScroller(target)\n    const behavior: ScrollBehavior = window.matchMedia?.(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches\n      ? \"auto\"\n      : \"smooth\"\n\n    // The browser's own fragment jump lands the heading flush with the top of the viewport,\n    // where a sticky header sits on top of it. Doing the scroll here is what `offset` buys.\n    if (scroller) {\n      const top =\n        target.getBoundingClientRect().top -\n        scroller.getBoundingClientRect().top +\n        scroller.scrollTop -\n        offset\n      scroller.scrollTo({ top, behavior })\n    } else {\n      const top = target.getBoundingClientRect().top + window.scrollY - offset\n      window.scrollTo({ top, behavior })\n    }\n\n    // Preventing the default also cancels the focus move the browser would have done, which\n    // is how a keyboard reader gets *into* the section: without this, tabbing on from the\n    // link carries on through the rest of the contents instead of the text just scrolled to.\n    // The attribute is borrowed rather than kept, so the page is left as it was found.\n    if (!target.hasAttribute(\"tabindex\")) {\n      target.setAttribute(\"tabindex\", \"-1\")\n      target.addEventListener(\"blur\", () => target.removeAttribute(\"tabindex\"), {\n        once: true,\n      })\n    }\n    target.focus({ preventScroll: true })\n\n    // replaceState rather than pushState: the URL stays shareable, but reading one long page\n    // does not bury the page the reader arrived from under a dozen back-button steps.\n    if (typeof history !== \"undefined\" && history.replaceState) {\n      history.replaceState(null, \"\", `#${id}`)\n    }\n\n    setActiveIndex(items.findIndex((item) => item.id === id))\n  }\n\n  if (items.length === 0) return null\n\n  const shallowest = Math.min(...items.map((item) => normalizeLevel(item.level)))\n\n  return (\n    <nav\n      ref={forwardedRef}\n      aria-label={label}\n      className={cn(\"text-sm\", className)}\n      {...props}\n    >\n      <ol className=\"space-y-1\">\n        {items.map((item, index) => {\n          const isActive = index === activeIndex\n          const steps = Math.min(\n            Math.max(normalizeLevel(item.level) - shallowest, 0),\n            MAX_INDENT_STEPS\n          )\n          return (\n            <li key={item.id}>\n              <a\n                href={`#${item.id}`}\n                onClick={(event) => handleClick(event, item.id)}\n                // aria-current=\"location\" — the reader is not on some other page, they are at\n                // a place within this one. Announced, unlike a colour change on its own.\n                aria-current={isActive ? \"location\" : undefined}\n                // Inline rather than a `pl-${n}` class: the depth is a runtime value, and a\n                // dynamic class name is invisible to Tailwind's scanner — it would work in\n                // dev and then silently vanish from the production build.\n                style={{ paddingLeft: steps * INDENT_STEP_PX }}\n                className={cn(\n                  \"block rounded-sm py-0.5 leading-snug transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n                  isActive\n                    ? \"font-medium text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\"\n                )}\n              >\n                {item.title}\n              </a>\n            </li>\n          )\n        })}\n      </ol>\n    </nav>\n  )\n})\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}