"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
export interface ScrollspyTocItem {
/** id of the section element this entry links to. */
id: string;
label: string;
/** Heading level (2 = top level, 3 = nested…). Deeper levels are indented. */
level?: number;
}
type Spring = { stiffness: number; damping: number; mass: number };
export interface ScrollspyTocProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
items: ScrollspyTocItem[];
/** Scrollable element that contains the sections. Defaults to the window. */
container?: React.RefObject<HTMLElement | null>;
/** Distance from the top of the scroll area where a section becomes active, in px (e.g. a sticky header's height). */
offset?: number;
/** Active marker: a line on the rail, or a pill behind the label. */
indicator?: "line" | "pill";
/** Fill each entry's rail segment as its section is read. */
showProgress?: boolean;
/** Smooth-scroll on click (instant with prefers-reduced-motion). */
smooth?: boolean;
/** Visible heading above the list. Empty string hides it. */
title?: string;
/** Accessible name of the navigation landmark. */
label?: string;
/** Spring for the indicator as it slides between entries. */
spring?: Spring;
/** Accent for the indicator, progress fill and focus ring. */
accent?: string;
/** Called when the active section changes. */
onActiveChange?: (id: string) => void;
}
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
/**
* A table of contents that follows the reader: IntersectionObserver picks the active section,
* a shared-layout indicator slides and resizes between entries, and each entry can show how much
* of its section has been read. Works with the window or any scrollable container.
*/
export function ScrollspyToc({
items,
container,
offset = 24,
indicator = "line",
showProgress = true,
smooth = true,
title = "On this page",
label = "Table of contents",
spring = { stiffness: 380, damping: 34, mass: 0.8 },
accent = "#FF7448",
onActiveChange,
className = "",
style,
...props
}: ScrollspyTocProps) {
const uid = React.useId();
const reduceMotion = useReducedMotion();
const [activeState, setActiveState] = React.useState<string | null>(null);
const activeId = activeState && items.some((item) => item.id === activeState) ? activeState : (items[0]?.id ?? null);
const linkRefs = React.useRef(new Map<string, HTMLAnchorElement>());
/** While a click-scroll runs, the clicked entry stays active instead of flickering through others. */
const lockRef = React.useRef<string | null>(null);
const baseLevel = Math.min(...items.map((item) => item.level ?? 2));
const setActive = (id: string) => {
if (id === activeId) return;
setActiveState(id);
onActiveChange?.(id);
};
const setActiveFromEffect = React.useEffectEvent(setActive);
// Keyed on the ids (not the array identity) so inline item arrays do not re-subscribe every render.
const idsKey = items.map((item) => item.id).join(" ");
React.useEffect(() => {
const root = container?.current ?? null;
const scroller: HTMLElement | Window = root ?? window;
const sections = idsKey
.split(" ")
.map((id) => (id ? document.getElementById(id) : null))
.filter((el): el is HTMLElement => el !== null);
if (!sections.length) return;
const visible = new Set<string>();
const metrics = () => {
if (root) return { top: root.getBoundingClientRect().top, scrollTop: root.scrollTop, height: root.clientHeight, scrollHeight: root.scrollHeight };
const doc = document.documentElement;
return { top: 0, scrollTop: window.scrollY, height: window.innerHeight, scrollHeight: doc.scrollHeight };
};
let frame = 0;
const update = () => {
frame = 0;
const m = metrics();
const atBottom = m.scrollTop + m.height >= m.scrollHeight - 2;
if (!lockRef.current) {
// At the very end, short trailing sections can never reach the activation line.
const next = atBottom ? sections[sections.length - 1].id : sections.find((s) => visible.has(s.id))?.id;
if (next) setActiveFromEffect(next);
}
if (!showProgress) return;
const line = m.top + offset;
sections.forEach((section, i) => {
const start = section.getBoundingClientRect().top;
const end = i < sections.length - 1 ? sections[i + 1].getBoundingClientRect().top : section.getBoundingClientRect().bottom;
const p = atBottom ? 1 : clamp01((line - start) / Math.max(1, end - start));
linkRefs.current.get(section.id)?.style.setProperty("--sp-progress", p.toFixed(3));
});
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(update);
};
// A band from the activation line down to 40% of the viewport: the first section inside it is active.
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target.id);
else visible.delete(entry.target.id);
}
schedule();
},
{ root, rootMargin: `-${offset}px 0px -60% 0px`, threshold: 0 },
);
sections.forEach((section) => observer.observe(section));
scroller.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
schedule();
return () => {
observer.disconnect();
scroller.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
cancelAnimationFrame(frame);
};
}, [idsKey, container, offset, showProgress]);
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {
const target = document.getElementById(id);
if (!target || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
event.preventDefault();
const root = container?.current ?? null;
const behavior: ScrollBehavior = smooth && !reduceMotion ? "smooth" : "auto";
// Land 2px past the activation line so the observer agrees with the click.
if (root) {
const top = target.getBoundingClientRect().top - root.getBoundingClientRect().top + root.scrollTop - offset + 2;
root.scrollTo({ top, behavior });
} else {
window.scrollTo({ top: target.getBoundingClientRect().top + window.scrollY - offset + 2, behavior });
}
lockRef.current = id;
setActive(id);
const scroller: HTMLElement | Window = root ?? window;
let timer = 0;
const release = () => {
lockRef.current = null;
window.clearTimeout(timer);
scroller.removeEventListener("scrollend", release);
};
scroller.addEventListener("scrollend", release, { once: true });
// Fallback when the target is already in place (no scroll) or scrollend is unsupported.
timer = window.setTimeout(release, behavior === "smooth" ? 1200 : 120);
// Move focus to the section for keyboard and screen reader users, without a second scroll.
if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
target.focus({ preventScroll: true });
};
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
return (
<nav aria-label={label} className={`text-sm ${className}`} style={{ ["--sp-accent" as string]: accent, ...style }} {...props}>
{title && (
<p aria-hidden="true" className="mb-3 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{title}
</p>
)}
<ol className="relative before:absolute before:inset-y-0 before:left-0 before:w-px before:bg-zinc-900/10 dark:before:bg-white/10">
{items.map((item) => {
const isActive = item.id === activeId;
const indent = Math.max(0, (item.level ?? 2) - baseLevel) * 12;
return (
<li key={item.id} className="relative">
<a
ref={(node) => {
if (node) linkRefs.current.set(item.id, node);
else linkRefs.current.delete(item.id);
}}
href={`#${item.id}`}
aria-current={isActive ? "location" : undefined}
onClick={(event) => handleClick(event, item.id)}
className={`relative block rounded-[6px] py-1.5 pr-2 leading-snug tracking-tight outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--sp-accent)] ${
indent ? "text-[13px]" : ""
} ${
isActive
? "font-medium text-zinc-950 dark:text-white"
: "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
}`}
style={{ paddingLeft: 14 + indent, ["--sp-progress" as string]: 0 }}
>
{showProgress && (
<span
aria-hidden="true"
className="absolute inset-y-0 left-0 w-[2px] -translate-x-[0.5px] origin-top scale-y-[var(--sp-progress)] bg-[color-mix(in_oklab,var(--sp-accent)_45%,transparent)]"
/>
)}
{isActive && (
<motion.span
aria-hidden="true"
// useId scopes the layoutId, so several tables of contents never share an indicator.
layoutId={`${uid}-indicator`}
transition={transition}
className={
indicator === "line"
? "absolute inset-y-0 left-0 w-[2px] -translate-x-[0.5px] rounded-full bg-[var(--sp-accent)]"
: "absolute inset-y-0 right-0 rounded-[6px] bg-zinc-900/[0.06] dark:bg-white/[0.07]"
}
style={indicator === "pill" ? { left: 6 + indent } : undefined}
/>
)}
<span className="relative">{item.label}</span>
</a>
</li>
);
})}
</ol>
</nav>
);
}