Scroll-spy TOC that tracks headings with an IntersectionObserver and slides a measured indicator between entries.
npx shadcn@latest add @paragon/table-of-contents"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TableOfContentsItem {
/** The id of the heading element this entry tracks. */
id: string;
label: string;
/** Nesting level, 1–3. Deeper levels indent. */
level?: 1 | 2 | 3;
}
export interface TableOfContentsProps extends React.ComponentProps<"nav"> {
items: TableOfContentsItem[];
/** Scroll container to observe against; defaults to the viewport. */
rootRef?: React.RefObject<HTMLElement | null>;
}
/**
* Scroll-spy table of contents. An IntersectionObserver tracks the headings
* through a reading band near the top of the scroll root; the active entry's
* indicator slides between links with a measured translateY at 150ms —
* smooth but instant-feeling. Clicks scroll smoothly (instant under reduced
* motion) and lock the indicator to the target so it doesn't hop through
* intermediate sections.
*/
export function TableOfContents({
items,
rootRef,
className,
...props
}: TableOfContentsProps) {
const [activeId, setActiveId] = React.useState<string | undefined>(
items[0]?.id,
);
const navRef = React.useRef<HTMLElement>(null);
const indicatorRef = React.useRef<HTMLSpanElement>(null);
const hasPositioned = React.useRef(false);
const clickLock = React.useRef<{
id: string;
timer: ReturnType<typeof setTimeout>;
} | null>(null);
const itemsKey = items.map((item) => item.id).join(" ");
React.useEffect(() => {
const ids = itemsKey.split(" ").filter(Boolean);
const headings = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);
if (headings.length === 0) return;
const visible = new Set<string>();
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target.id);
else visible.delete(entry.target.id);
}
const next = ids.find((id) => visible.has(id));
if (!next) return;
if (clickLock.current) {
if (next !== clickLock.current.id) return;
clearTimeout(clickLock.current.timer);
clickLock.current = null;
}
setActiveId(next);
},
{ root: rootRef?.current ?? null, rootMargin: "0% 0% -55% 0%" },
);
for (const heading of headings) observer.observe(heading);
return () => observer.disconnect();
}, [itemsKey, rootRef]);
React.useEffect(() => {
return () => {
if (clickLock.current) clearTimeout(clickLock.current.timer);
};
}, []);
const position = React.useCallback((animate: boolean) => {
const nav = navRef.current;
const indicator = indicatorRef.current;
if (!nav || !indicator) return;
const active = nav.querySelector<HTMLElement>('[data-active="true"]');
if (!active) {
indicator.style.opacity = "0";
return;
}
let top = 0;
let node: HTMLElement | null = active;
while (node && node !== nav) {
top += node.offsetTop;
node = node.offsetParent instanceof HTMLElement ? node.offsetParent : null;
}
if (!animate) indicator.style.transitionProperty = "none";
indicator.style.opacity = "1";
indicator.style.height = `${active.offsetHeight}px`;
indicator.style.transform = `translateY(${top}px)`;
if (!animate) {
void indicator.offsetWidth;
indicator.style.transitionProperty = "";
}
}, []);
React.useLayoutEffect(() => {
position(hasPositioned.current);
hasPositioned.current = true;
}, [position, activeId]);
React.useEffect(() => {
const nav = navRef.current;
if (!nav) return;
const observer = new ResizeObserver(() => position(false));
observer.observe(nav);
return () => observer.disconnect();
}, [position]);
const onLinkClick = (
event: React.MouseEvent<HTMLAnchorElement>,
id: string,
) => {
event.preventDefault();
const target = document.getElementById(id);
if (!target) return;
setActiveId(id);
if (clickLock.current) clearTimeout(clickLock.current.timer);
clickLock.current = {
id,
timer: setTimeout(() => {
clickLock.current = null;
}, 1000),
};
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
target.scrollIntoView({
behavior: reduced ? "auto" : "smooth",
block: "start",
});
};
return (
<nav
ref={navRef}
aria-label="Table of contents"
data-slot="table-of-contents"
className={cn("relative w-full max-w-56", className)}
{...props}
>
<span
ref={indicatorRef}
aria-hidden
className="pointer-events-none absolute top-0 -left-px w-0.5 rounded-full bg-foreground opacity-0 [transition-property:transform,height,opacity] duration-150 ease-out motion-reduce:transition-none"
/>
<ul className="flex flex-col border-l">
{items.map((item) => {
const level = item.level ?? 1;
return (
<li key={item.id}>
<a
href={`#${item.id}`}
data-active={item.id === activeId || undefined}
onClick={(event) => onLinkClick(event, item.id)}
style={{ paddingLeft: `${12 + (level - 1) * 12}px` }}
className="flex h-7 items-center rounded-r-md pr-2 text-[13px] text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground data-active:text-foreground"
>
<span className="truncate">{item.label}</span>
</a>
</li>
);
})}
</ul>
</nav>
);
}