{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toc",
  "type": "registry:ui",
  "title": "Toc",
  "description": "The in-page Table of Contents. Long-form pages (docs, MDX articles, rule reference) need a persistent navigation rail that mirrors the heading outline so a reader can both orient themselves and jump. On wide viewports this lives in a right-hand rail…",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "blog",
    "primitive"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/popover.json",
    "https://ds.interlace.tools/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/toc.tsx",
      "target": "components/ui/toc.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\nimport * as React from 'react';\n\n// @interlace/toc v1.0.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/toc\n// What changed since: https://ds.interlace.tools/c/toc#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — Toc + TocPopover\n *\n * The in-page Table of Contents. Long-form pages (docs, MDX articles, rule\n * reference) need a persistent navigation rail that mirrors the heading\n * outline so a reader can both orient themselves and jump. On wide\n * viewports this lives in a right-hand rail (`Toc`); on narrow viewports\n * it folds into a popover trigger (`TocPopover`) so the content column\n * keeps the full reading width.\n *\n * Two contracts make this primitive non-trivial:\n *\n *   1. **Active-section tracking.** We observe the headings the consumer\n *      gave us via `IntersectionObserver` so the rail highlights the\n *      section currently under the reader's eye. No scroll-handler math,\n *      no manual `rAF` — the browser does it.\n *\n *   2. **Reduced-motion contract.** Smooth-scroll on click is enabled by\n *      default, but the `useReducedMotion` hook flips it to `instant` for\n *      users with `prefers-reduced-motion: reduce` per\n *      `MOTION_PHILOSOPHY.md`.\n *\n * ## Anatomy\n *\n *   Toc                                  (nav — data-min-viewport=480)\n *     └─ <ol data-slot=\"toc-list\">       (top-level heading list)\n *          └─ <li data-slot=\"toc-item\" data-level=\"2|3|4\" data-active>\n *               └─ <a data-slot=\"toc-link\" href=\"#…\">\n *\n *   TocPopover                           (Popover wrapper — data-min-viewport=480)\n *     ├─ <Popover.Trigger> \"On this page\"\n *     └─ <Popover.Content> ⟶ <Toc />\n *\n * ## MIN_VIEWPORT — 480\n *\n * The TOC is a *companion* surface — the article itself must always work\n * on a 320 phone, but the TOC rail/popover only earns its place once we\n * have horizontal room for a meaningful \"where am I in the doc\" label.\n * Below 480 CSS px the consumer should hide the TOC entirely and rely on\n * scroll + headings; the popover form is the smallest UX that's still\n * worth the screen real estate.\n *\n * | Rule | Concept                          | Where in this file                                                  |\n * | ---- | -------------------------------- | ------------------------------------------------------------------- |\n * | R4   | Extends native el + props        | `React.ComponentProps<'nav'> & TocProps`                            |\n * | R6   | data-slot on every part          | `data-slot=\"toc\" / \"toc-list\" / \"toc-item\" / \"toc-link\"`            |\n * | R7   | className merged + ...rest       | `cn(BASE, className)` + `{...rest}` on root nav                     |\n * | R8   | No `isXxx`; enums for levels     | `level: 2 \\| 3 \\| 4` is a discriminated enum, not a boolean         |\n * | R10  | Composition seam                 | `TocPopover` wraps `Toc` via `<Popover>` parts                      |\n * | R14  | Declares min viewport            | `data-min-viewport={String(MIN_VIEWPORT)}` + exported const         |\n * | R18  | Tailwind only                    | Zero inline `style`; utility classes + cva                          |\n * | R19  | Tokens only                      | `pl-md` / `pl-lg` spacing, semantic color tokens                    |\n * | R20  | AA contrast                      | `text-muted-foreground` / active `text-foreground`                  |\n * | R25  | Client component                 | Hooks (`useEffect`, `useState`) + `useReducedMotion` → `'use client'` |\n * | R26  | A11y from native el              | `<nav aria-label>` landmark + `<ol>/<a>` semantics                  |\n */\n\nimport { cn } from '@/lib/utils';\nimport { useReducedMotion } from '@/hooks/use-reduced-motion';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from '@/components/ui/popover';\n\n/**\n * Minimum viable viewport (CSS px) for this primitive. Below it, the\n * preflight contract draws a dev-mode outline; consumers should hide the\n * TOC entirely on narrower viewports rather than rely on the popover.\n */\nexport const MIN_VIEWPORT = 480 as const;\n\n/**\n * One heading entry. `id` must match the `id` attribute on the heading\n * element in the document (the same anchor the reader hits via `#id`).\n */\nexport type TocItem = {\n  /** Heading element id — same value as the `<h2 id=\"…\">` attribute. */\n  id: string;\n  /** Visible label — usually the heading text itself. */\n  label: React.ReactNode;\n  /** Heading level. h1 is the page title and never appears in a TOC. */\n  level: 2 | 3 | 4;\n};\n\ntype TocProps = Omit<React.ComponentProps<'nav'>, 'children'> & {\n  /** Ordered list of headings (preserved 1:1 in the rendered list). */\n  items: TocItem[];\n  /** Optional accessible name override. Defaults to \"Table of contents\". */\n  label?: string;\n};\n\nconst LEVEL_INDENT: Record<TocItem['level'], string> = {\n  2: '',\n  3: 'pl-md',\n  4: 'pl-lg',\n};\n\nconst TocComponent = React.forwardRef<HTMLElement, TocProps>(\n  ({ className, items, label = 'Table of contents', ...rest }, ref) => {\n    const reduceMotion = useReducedMotion();\n    const [activeId, setActiveId] = React.useState<string | null>(null);\n\n    // Track which section is in view via IntersectionObserver. We observe\n    // every heading element whose id is in `items`; the top-most visible\n    // one wins. No scroll handlers, no rAF — the platform does it.\n    React.useEffect(() => {\n      if (typeof window === 'undefined' || items.length === 0) return;\n\n      const nodes: HTMLElement[] = [];\n      for (const item of items) {\n        const node = document.getElementById(item.id);\n        if (node) nodes.push(node);\n      }\n      if (nodes.length === 0) return;\n\n      // Track visibility per id; on each callback pick the top-most.\n      const visible = new Map<string, boolean>();\n\n      const observer = new IntersectionObserver(\n        (entries) => {\n          for (const entry of entries) {\n            visible.set(entry.target.id, entry.isIntersecting);\n          }\n          // Walk items in source order and pick the first visible one.\n          // Falls back to the last item passed if nothing is visible.\n          let nextActive: string | null = null;\n          for (const item of items) {\n            if (visible.get(item.id)) {\n              nextActive = item.id;\n              break;\n            }\n          }\n          setActiveId(nextActive);\n        },\n        {\n          // Headings are \"active\" once they pass the top quarter of the\n          // viewport and stay so until the next one takes over — same\n          // contract as MDX docs sites (Next.js docs, base-ui.com).\n          rootMargin: '0px 0px -70% 0px',\n          threshold: [0, 1],\n        },\n      );\n\n      for (const node of nodes) observer.observe(node);\n      return () => observer.disconnect();\n    }, [items]);\n\n    const handleClick = React.useCallback(\n      (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {\n        // Let modifier-clicks (cmd/ctrl/middle) fall through to the\n        // browser so \"open in new tab\" still works.\n        if (\n          event.defaultPrevented ||\n          event.metaKey ||\n          event.ctrlKey ||\n          event.shiftKey ||\n          event.altKey ||\n          event.button !== 0\n        ) {\n          return;\n        }\n        const target = document.getElementById(id);\n        if (!target) return;\n        event.preventDefault();\n        target.scrollIntoView({\n          behavior: reduceMotion ? 'instant' : 'smooth',\n          block: 'start',\n        });\n        // Update the URL hash without a second scroll jump.\n        if (typeof history !== 'undefined') {\n          history.pushState(null, '', `#${id}`);\n        }\n        // Move focus into the target so screen readers / keyboard users\n        // land where sighted users do.\n        target.focus({ preventScroll: true });\n      },\n      [reduceMotion],\n    );\n\n    return (\n      <nav\n        ref={ref}\n        aria-label={label}\n        data-slot=\"toc\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        className={cn('text-ui-sm', className)}\n        {...rest}\n      >\n        <ol data-slot=\"toc-list\" className=\"flex flex-col gap-xs\">\n          {items.map((item) => {\n            const isActive = item.id === activeId;\n            return (\n              <li\n                key={item.id}\n                data-slot=\"toc-item\"\n                data-level={item.level}\n                data-active={isActive ? '' : undefined}\n                className={cn(LEVEL_INDENT[item.level])}\n              >\n                <a\n                  data-slot=\"toc-link\"\n                  href={`#${item.id}`}\n                  aria-current={isActive ? 'location' : undefined}\n                  onClick={(event) => handleClick(event, item.id)}\n                  className={cn(\n                    'block rounded-sm py-xs transition-colors',\n                    'text-muted-foreground hover:text-foreground',\n                    'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n                    'data-[active]:text-foreground data-[active]:font-medium',\n                  )}\n                  data-active={isActive ? '' : undefined}\n                >\n                  {item.label}\n                </a>\n              </li>\n            );\n          })}\n        </ol>\n      </nav>\n    );\n  },\n);\nTocComponent.displayName = 'Toc';\n\nexport const Toc = TocComponent;\nexport type { TocProps };\n\ntype TocPopoverProps = TocProps & {\n  /** Trigger label — defaults to \"On this page\". */\n  triggerLabel?: React.ReactNode;\n  /** Class name applied to the popover trigger button. */\n  triggerClassName?: string;\n};\n\n/**\n * Narrow-viewport companion to `Toc`. Renders a \"On this page\" trigger\n * (`<Popover.Trigger>`) and a popover that contains the full `Toc`\n * markup. Same active-tracking + reduced-motion contract — the\n * popover does not own the TOC state, it just hosts the rendered tree.\n */\nexport function TocPopover({\n  items,\n  label,\n  triggerLabel = 'On this page',\n  triggerClassName,\n  className,\n  ...rest\n}: TocPopoverProps) {\n  return (\n    <Popover>\n      <PopoverTrigger\n        data-slot=\"toc-popover-trigger\"\n        data-min-viewport={String(MIN_VIEWPORT)}\n        className={cn(\n          'inline-flex items-center gap-xs rounded-md border border-border bg-background px-sm py-xs text-ui-sm',\n          'text-foreground hover:bg-accent hover:text-accent-foreground',\n          'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n          triggerClassName,\n        )}\n      >\n        {triggerLabel}\n      </PopoverTrigger>\n      <PopoverContent\n        data-slot=\"toc-popover-content\"\n        align=\"start\"\n        className=\"w-72 p-sm\"\n      >\n        <Toc\n          items={items}\n          label={label}\n          className={className}\n          {...rest}\n        />\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
    }
  ],
  "meta": {
    "tier": "primitive",
    "client": true,
    "minViewport": 480,
    "loading": false,
    "version": "1.0.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/toc\n\nInstalled to `components/ui/toc.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/toc';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/toc\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
