{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"toc-progress","type":"registry:ui","title":"Toc progress","description":"A table of contents that reads along with you: a thin rail bends through the nested headings and fills as the page scrolls, a marker rides it to the current section, and the reading time left counts down.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/lib/scroll-view.ts","type":"registry:ui","target":"components/ui/lib/scroll-view.ts","content":"/** The nearest ancestor that scrolls vertically, or null for the page. */\nexport function scrollParent(element: HTMLElement): HTMLElement | null {\n  for (let node = element.parentElement; node && node !== document.body; node = node.parentElement) {\n    const { overflowY } = getComputedStyle(node);\n    if ((overflowY === \"auto\" || overflowY === \"scroll\") && node.scrollHeight > node.clientHeight) return node;\n  }\n  return null;\n}\n\n/** The visible band of a scroller in viewport coordinates: its top edge and height (the window when null). */\nexport function viewOf(scroller: HTMLElement | null) {\n  if (!scroller) return { top: 0, height: window.innerHeight };\n  return { top: scroller.getBoundingClientRect().top + scroller.clientTop, height: scroller.clientHeight };\n}\n"},{"path":"ui/toc-progress.tsx","type":"registry:ui","target":"components/ui/toc-progress.tsx","content":"\"use client\";\nimport { useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type MouseEvent } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport { scrollParent, viewOf } from \"./lib/scroll-view\";\nimport \"./toc-progress.css\";\n\nexport type TocItem = { id: string; label: string; level?: 1 | 2 | 3 };\n\nexport type TocProgressProps = Omit<ComponentProps<\"nav\">, \"children\"> & {\n  /** The headings, in page order; each `id` is the heading element's id. */\n  items: TocItem[];\n  /** Minutes the whole page takes to read; shows the time left when given. */\n  minutes?: number;\n  label?: string;\n};\n\nconst indent = 12;\nconst xOf = (level = 1) => 4 + (level - 1) * indent;\n\n/** The rail's path: straight down beside each row at its level's indent, bending between levels. */\nfunction railPath(rows: { top: number; bottom: number; level?: number }[]) {\n  return rows\n    .map((row, i) => {\n      const x = xOf(row.level);\n      const start = i === 0 ? `M${x} ${row.top + 4}` : \"\";\n      const next = rows[i + 1];\n      if (!next) return `${start} L${x} ${row.bottom - 4}`;\n      const nx = xOf(next.level);\n      const turn = Math.min(10, (row.bottom - row.top) / 2);\n      const mid = (row.bottom + next.top) / 2;\n      return `${start} L${x} ${mid - turn} C${x} ${mid} ${nx} ${mid} ${nx} ${mid + turn}`;\n    })\n    .join(\" \");\n}\n\n/**\n * A table of contents that reads along with you. A thin rail bends through the nested headings and fills as the page\n * scrolls; a marker rides the rail to the section you are in, which is also marked as the current link. Choosing a\n * link moves there and puts focus on the heading. With `minutes` it counts down the reading time left. Works in a\n * scrolling panel as well as the page. Under reduced motion the jump is instant.\n */\nexport function TocProgress({ items, minutes, label = \"On this page\", className, ...props }: TocProgressProps) {\n  const list = useRef<HTMLOListElement>(null);\n  const fill = useRef<SVGPathElement>(null);\n  const marker = useRef<SVGCircleElement>(null);\n  const [path, setPath] = useState({ d: \"\", height: 0, rows: [] as { top: number; bottom: number }[] });\n  const [active, setActive] = useState(0);\n  const [left, setLeft] = useState<number | null>(minutes ?? null);\n\n  useLayoutEffect(() => {\n    const ol = list.current;\n    if (!ol) return;\n    const measure = () => {\n      const rows = [...ol.children].map((li, i) => {\n        const el = li as HTMLElement;\n        return { top: el.offsetTop, bottom: el.offsetTop + el.offsetHeight, level: items[i]?.level };\n      });\n      setPath({ d: railPath(rows), height: ol.offsetHeight, rows });\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(ol);\n    return () => observer.disconnect();\n  }, [items]);\n\n  useEffect(() => {\n    const headings = items.map((item) => document.getElementById(item.id));\n    const first = headings.find(Boolean);\n    const line = fill.current;\n    if (!first || !line || !path.d) return;\n    const scroller = scrollParent(first);\n    const total = line.getTotalLength();\n    // The rail goes down the page, so a length is found from a height by halving.\n    const lengthAt = (y: number) => {\n      let lo = 0,\n        hi = total;\n      for (let i = 0; i < 18; i++) {\n        const mid = (lo + hi) / 2;\n        if (line.getPointAtLength(mid).y < y) lo = mid;\n        else hi = mid;\n      }\n      return lo;\n    };\n    let frame = 0;\n    const update = () => {\n      frame = 0;\n      const view = viewOf(scroller);\n      const reading = view.top + view.height * 0.3;\n      const tops = headings.map((heading) => (heading ? heading.getBoundingClientRect().top : Infinity));\n      let index = 0;\n      tops.forEach((top, i) => top <= reading && (index = i));\n      const after = tops[index + 1];\n      const end = scroller ? scroller.getBoundingClientRect().top + scroller.scrollHeight - scroller.scrollTop : document.documentElement.scrollHeight - scrollY;\n      const sectionEnd = Number.isFinite(after) ? after : Math.max(end - view.height * 0.7, tops[index] + 1);\n      const within = Math.min(1, Math.max(0, (reading - tops[index]) / (sectionEnd - tops[index])));\n      const row = path.rows[index];\n      const next = path.rows[index + 1];\n      const y = row ? row.top + 4 + ((next ? next.top + 4 : row.bottom - 4) - row.top - 4) * (tops[0] > reading ? 0 : within) : 0;\n      const length = lengthAt(y);\n      line.style.strokeDasharray = `${length} ${total + 1}`;\n      const point = line.getPointAtLength(length);\n      if (marker.current) marker.current.style.transform = `translate(${point.x}px, ${point.y}px)`;\n      setActive(index);\n      const scrolled = scroller ? scroller.scrollTop / Math.max(1, scroller.scrollHeight - scroller.clientHeight) : scrollY / Math.max(1, document.documentElement.scrollHeight - innerHeight);\n      if (minutes) setLeft(Math.ceil(minutes * (1 - Math.min(1, scrolled)) - 0.05));\n    };\n    const schedule = () => (frame ||= requestAnimationFrame(update));\n    schedule();\n    const target = scroller ?? window;\n    target.addEventListener(\"scroll\", schedule, { passive: true });\n    addEventListener(\"resize\", schedule);\n    return () => {\n      cancelAnimationFrame(frame);\n      target.removeEventListener(\"scroll\", schedule);\n      removeEventListener(\"resize\", schedule);\n    };\n  }, [items, path, minutes]);\n\n  const go = (event: MouseEvent<HTMLAnchorElement>, id: string) => {\n    const heading = document.getElementById(id);\n    if (!heading) return;\n    event.preventDefault();\n    const still = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    heading.scrollIntoView({ behavior: still ? \"auto\" : \"smooth\", block: \"start\" });\n    if (!heading.hasAttribute(\"tabindex\")) heading.setAttribute(\"tabindex\", \"-1\");\n    heading.focus({ preventScroll: true });\n  };\n\n  return (\n    <nav aria-label={label} data-slot=\"toc-progress\" className={cn(\"grid gap-3\", className)} {...props}>\n      <div className=\"flex items-baseline justify-between gap-3\">\n        <p data-slot=\"tp-title\" className=\"text-sm font-medium\">{label}</p>\n        {left !== null && <p data-slot=\"tp-left\" className=\"text-xs tabular-nums text-muted-foreground\">{left <= 0 ? \"Finished\" : `${left} min left`}</p>}\n      </div>\n      <div data-slot=\"tp-body\">\n        <svg data-slot=\"tp-rail\" aria-hidden=\"true\" width={xOf(3) + 6} height={path.height}>\n          <path data-slot=\"tp-track\" d={path.d} />\n          <path ref={fill} data-slot=\"tp-fill\" d={path.d} />\n          <circle ref={marker} data-slot=\"tp-marker\" r={3.5} />\n        </svg>\n        <ol ref={list}>\n          {items.map((item, i) => (\n            <li key={item.id} style={{ paddingLeft: xOf(item.level) + 16 }}>\n              <a href={`#${item.id}`} data-slot=\"tp-link\" data-read={i < active ? \"\" : undefined} aria-current={i === active ? \"location\" : undefined} onClick={(event) => go(event, item.id)}>\n                {item.label}\n              </a>\n            </li>\n          ))}\n        </ol>\n      </div>\n    </nav>\n  );\n}\n"},{"path":"ui/toc-progress.css","type":"registry:file","target":"components/ui/toc-progress.css","content":"/* toc-progress.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Toc progress: the rail bends with the nesting; the fill and marker follow the reading line. */\n  [data-slot=\"toc-progress\"] [data-slot=\"tp-body\"] { position: relative; }\n  [data-slot=\"toc-progress\"] [data-slot=\"tp-rail\"] { position: absolute; top: 0; left: 0; overflow: visible; pointer-events: none; }\n  [data-slot=\"toc-progress\"] [data-slot=\"tp-track\"] { fill: none; stroke: var(--border); stroke-width: 1.5; }\n  [data-slot=\"toc-progress\"] [data-slot=\"tp-fill\"] { fill: none; stroke: var(--foreground); stroke-width: 1.5; stroke-linecap: round; stroke-dasharray: 0 100000; transition: stroke-dasharray 0.16s linear; }\n  [data-slot=\"toc-progress\"] [data-slot=\"tp-marker\"] { fill: var(--background); stroke: var(--foreground); stroke-width: 1.5; transition: transform 0.16s linear; }\n  [data-slot=\"toc-progress\"] ol { display: grid; margin: 0; padding: 0; list-style: none; }\n  [data-slot=\"toc-progress\"] [data-slot=\"tp-link\"] { display: block; padding: 5px 0; border-radius: 4px; color: var(--muted-foreground); font-size: 13px; line-height: 1.35; text-decoration: none; transition: color var(--duration-fast) var(--ease-out); }\n  [data-slot=\"toc-progress\"] [data-slot=\"tp-link\"]:hover, [data-slot=\"toc-progress\"] [data-slot=\"tp-link\"][aria-current] { color: var(--foreground); }\n  [data-slot=\"toc-progress\"] [data-slot=\"tp-link\"][aria-current] { font-weight: 500; }\n  [data-slot=\"toc-progress\"] [data-slot=\"tp-link\"]:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"toc-progress\"] :is([data-slot=\"tp-fill\"], [data-slot=\"tp-marker\"]) { transition: none; }\n  }\n}\n"}],"categories":["motion"],"meta":{"tier":"free","license":"MIT","family":"browse","docs":"/docs/components/toc-progress"}}