{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "table-of-contents",
  "title": "Table Of Contents",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An 'On this page' list with a scroll spy and a sliding current-section marker. It collects headings itself or takes a list, and scrolls below sticky headers.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/scroll.json",
    "https://ui.ballmac.com/r/motion-presets.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/table-of-contents.tsx",
      "content": "// Ballmac UI: Table Of Contents. https://ui.ballmac.com/components/table-of-contents\n\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { scrollToId, useScrollSpy, type ScrollContainer } from \"@/lib/ballmac/scroll\";\nimport { spring } from \"@/lib/ballmac/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { useMessages } from \"@/lib/ballmac/i18n\";\n\ntype TocItem = {\n  /** The id of the heading in the page. */\n  id: string;\n  /** Text of the link. */\n  title: string;\n  /** Heading level (2 = top level, 3 = indented, and so on). */\n  level?: number;\n};\n\ntype TableOfContentsProps = Omit<React.ComponentProps<\"nav\">, \"children\"> & {\n  /** Links to show. Leave out to collect headings from the page automatically. */\n  items?: TocItem[];\n  /** Where to look for headings when `items` is not given. Defaults to `<main>`, then the whole page. */\n  headingsFrom?: React.RefObject<HTMLElement | null>;\n  /** Heading levels to collect automatically. */\n  levels?: number[];\n  /** Title above the list. Also the accessible name of the navigation. */\n  title?: string;\n  /** Pixels reserved at the top for a sticky header; also where a section counts as \"reached\". */\n  offset?: number;\n  /** A scrollable element to watch and scroll instead of the page. */\n  container?: ScrollContainer;\n  /** Called when the reader chooses a link. */\n  onNavigate?: (id: string) => void;\n};\n\nfunction slug(text: string) {\n  return text\n    .toLowerCase()\n    .trim()\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/^-+|-+$/g, \"\");\n}\n\n/**\n * \"On this page\" navigation with a scroll spy. The current section is marked with a sliding indicator and\n * `aria-current=\"location\"`; choosing a link scrolls smoothly (instantly under reduced motion) below a sticky header.\n */\nfunction TableOfContents({\n  items: itemsProp,\n  headingsFrom,\n  levels = [2, 3],\n  title,\n  offset = 96,\n  container,\n  onNavigate,\n  className,\n  ...props\n}: TableOfContentsProps) {\n  const msg = useMessages()\n  title ??= msg(\"table-of-contents.title\", \"On this page\")\n  const reduce = useReducedMotion();\n  const indicatorId = `toc-${React.useId()}`;\n  const [found, setFound] = React.useState<TocItem[]>([]);\n  const levelKey = levels.join(\",\");\n\n  React.useEffect(() => {\n    if (itemsProp) return;\n    const frame = requestAnimationFrame(() => {\n      const scope = headingsFrom?.current ?? document.querySelector(\"main\") ?? document.body;\n      const selector = levelKey.split(\",\").map((l) => `h${l}`).join(\",\");\n      const next: TocItem[] = [];\n      scope.querySelectorAll<HTMLElement>(selector).forEach((heading) => {\n        const text = heading.textContent?.trim() ?? \"\";\n        if (!text) return;\n        if (!heading.id) heading.id = slug(text);\n        next.push({ id: heading.id, title: text, level: Number(heading.tagName.slice(1)) });\n      });\n      setFound(next);\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [itemsProp, headingsFrom, levelKey]);\n\n  const items = itemsProp ?? found;\n  const ids = React.useMemo(() => items.map((i) => i.id), [items]);\n  const active = useScrollSpy(ids, { offset, container });\n  const base = items.length ? Math.min(...items.map((i) => i.level ?? 2)) : 2;\n  if (!items.length) return null;\n  return (\n    <nav aria-label={title} data-slot=\"table-of-contents\" className={cn(\"text-sm\", className)} {...props}>\n      <p className=\"mb-3 text-xs font-semibold tracking-wide text-foreground uppercase\">{title}</p>\n      <ul className=\"relative grid gap-0.5 border-s border-border\">\n        {items.map((item) => {\n          const isActive = item.id === active;\n          return (\n            <li key={item.id}>\n              <a\n                href={`#${item.id}`}\n                aria-current={isActive ? \"location\" : undefined}\n                data-active={isActive || undefined}\n                onClick={(event) => {\n                  event.preventDefault();\n                  if (scrollToId(item.id, { offset: offset - 8, container })) {\n                    history.replaceState(null, \"\", `#${item.id}`);\n                    onNavigate?.(item.id);\n                  }\n                }}\n                style={{ paddingInlineStart: `${0.75 + ((item.level ?? 2) - base) * 0.75}rem` }}\n                className=\"relative -ms-px block rounded-e-md py-1.5 pe-2 leading-snug text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[active]:font-medium data-[active]:text-foreground\"\n              >\n                {isActive && (\n                  <motion.span\n                    layoutId={indicatorId}\n                    aria-hidden=\"true\"\n                    className=\"absolute inset-y-1 start-0 w-0.5 rounded-full bg-primary\"\n                    transition={reduce ? { duration: 0 } : spring.snappy}\n                  />\n                )}\n                {item.title}\n              </a>\n            </li>\n          );\n        })}\n      </ul>\n    </nav>\n  );\n}\n\nexport { TableOfContents, type TableOfContentsProps, type TocItem };\n",
      "type": "registry:ui",
      "target": "@components/ballmac/table-of-contents.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "toc",
      "scrollspy",
      "docs",
      "anchor links"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "Leave items out to collect h2 and h3 headings (they need text; missing ids are created). Current section gets aria-current=location.",
      "whenToUse": [
        "Documentation and long articles",
        "Settings pages with many sections"
      ],
      "whenNotToUse": [
        "Horizontal in-page tabs; use section-tabs",
        "App navigation; use sidebar"
      ],
      "composesWith": [
        "scroll-progress",
        "section-tabs"
      ],
      "a11y": [
        {
          "keys": "Enter",
          "action": "Scrolls to the section and updates the URL hash"
        },
        {
          "keys": "Screen readers",
          "action": "A labelled navigation; the current section has aria-current=location"
        },
        {
          "keys": "Reduced motion",
          "action": "Jumps instead of smooth scrolling; the marker does not glide"
        }
      ],
      "customization": [
        "items or headingsFrom",
        "levels",
        "title",
        "offset and container"
      ]
    },
    "examples": [
      "table-of-contents-demo",
      "table-of-contents-states"
    ],
    "url": "https://ui.ballmac.com/components/table-of-contents"
  },
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}