TOC Scrollspy
An article outline that follows the reader — headings are watched with one IntersectionObserver and a rail slides to the current section.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/toc-scrollspy.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TocScrollspy" component (no animation
library — IntersectionObserver plus one measured transform).
Contract
- Export a forwardRef <nav> extending React.HTMLAttributes<HTMLElement>.
- headings?: { id, text, level }[] — omit it and the component collects every
h2/h3 that has an id, from containerRef's subtree or the whole document.
- containerRef?: React.RefObject<HTMLElement | null> — the scroll container
holding the article; omit to spy on the page scroll.
- offset?: number (default 80) — sticky-header height in px. It shifts both the
observer band and the click scroll target, so the two never disagree.
- onNavigate?: (id: string) => void — fired after a click. The component never
writes to location; the consumer decides whether the id becomes the hash.
Behavior
- Auto-collect runs in an effect (the DOM must exist first): querySelectorAll
"h2, h3" within the scope, keep the ones with an id, and map tagName to
level 2 / 3. An explicit headings prop skips collection entirely.
- Spy: one IntersectionObserver over every heading element, with
root = container (or null for the viewport) and
rootMargin = `${-offset}px 0px -70% 0px`, so the "readable band" is a strip
just under the sticky header. Maintain a Set of intersecting ids in the
callback; the active heading is the first one in document order that is
currently in the band.
- Fallback: when a section is taller than the band, nothing intersects — pick
the last heading whose bounding-rect top is above the offset line instead, so
the outline never blanks out mid-section.
- End of scroll wins over both: the final heading often cannot reach the band
at all (there is no scroll distance left below it), so it would never
highlight and clicking it would bounce the highlight back to the previous
section. When scrollTop + clientHeight has reached scrollHeight (window
scrollY + innerHeight vs documentElement.scrollHeight for page scroll), force
the last heading active. Guard the check with "the box actually scrolls", or
a short, non-scrolling article would pin the highlight to its last entry.
- Recompute on window resize as well; disconnect the observer and remove both
listeners on unmount. Re-run the effect when the heading list, container or
offset changes — depend on the heading ids joined into a string, not on the
array itself, or an inline headings={[...]} literal rebuilds the observer on
every render.
- The last stretch of scrolling may cross no heading at all, so the observer
goes quiet exactly where the end-of-scroll rule matters. Add a passive
"scroll" listener on the container (or window) that re-picks only when the
"am I at the bottom" answer flips — one cheap scroll-metric read per event,
no rect measuring per frame.
- Click: entries are real <a href={"#" + id}> anchors, but onClick calls
preventDefault and scrolls manually so the offset is honoured — container
? container.scrollTo({ top: container.scrollTop + rect.top -
containerRect.top - offset }) : window.scrollTo({ top: scrollY + rect.top -
offset }), with behavior "smooth", or "auto" when
matchMedia("(prefers-reduced-motion: reduce)").matches. Set the active id
optimistically, then call onNavigate.
- Rail: keep a Map of heading id -> <li> element via callback refs; measure the
active item's offsetTop / offsetHeight (the <ul> is position:relative, hence
the offsetParent) and move the rail by translateY. Re-measure with a
ResizeObserver on the list (titles rewrap when the sidebar narrows) and
enable the transition one frame after the first measurement so it does not
slide down from the top on mount.
- Render nothing at all (return null) when the heading list is empty — an empty
outline shell is worse than no outline.
- Resolve heading elements with an attribute selector ([id="..."]) rather than
"#id", so ids containing dots or colons need no escaping.
Rendering & styling
- Semantic tokens only: the <ul> carries border-l as the track; the rail is an
absolute w-0.5 bg-primary sitting on that border; active entries are
text-foreground font-medium, the rest text-muted-foreground with
hover:text-foreground and transition-colors.
- Indentation encodes hierarchy: level >= 3 gets a deeper left padding than
level 2. Nothing else changes between levels.
- Accessibility: <nav aria-label="Table of contents">, aria-current="location"
on the active entry, focus-visible:ring-2 ring-ring ring-inset on each link.
The rail is aria-hidden + pointer-events-none.
- Reduced motion: motion-reduce:transition-none on the rail and behavior:"auto"
for the scroll — the outline stays fully functional, it just stops animating.
- Merge consumer className on the <nav> via cn() — width, stickiness and
max-height are layout decisions that belong to the page.
Customization levers
- Depth: add "h4" to the querySelectorAll and one more indent branch to support
three levels; the observer logic is level-agnostic.
- Band position: the -70% bottom margin decides how eagerly the highlight
advances — -60% highlights later, -85% highlights earlier.
- offset should match your sticky header's height exactly; it is the only
number shared by the observer and the click scroll.
- Rail styling: swap the w-0.5 bg-primary rail for a filled bg-accent row
(change translateY to the same measurement, widen to inset-x-0) for a
"pill" outline instead of a rail.
- Sticky sidebar: pass className="sticky top-24 max-h-[70vh] overflow-auto" —
the component itself makes no positioning assumptions.
- URL sync: in onNavigate call history.replaceState (or a router's replace) if
you want shareable anchors without adding a history entry per click.Concepts
- Readable band — instead of asking "is this heading visible", the observer shrinks the root to a strip under the sticky header; a heading is "current" while it sits in that strip, which is what makes the highlight change at the moment the reader crosses it.
- Highest-visible wins — when two headings share the band (short sections), the one nearest the top is chosen, so the outline advances in document order and never flickers between neighbours.
- Above-the-line fallback — sections taller than the band leave the observer with nothing intersecting; falling back to the last heading that has already scrolled past the offset keeps the highlight pinned to the section you are actually reading.
- End-of-scroll pin — the last heading usually runs out of scroll before it can reach the band, so without a dedicated "we are at the bottom" branch it could never be current and clicking it would snap back to its predecessor; reaching the end of the container (or the page) is itself the signal that the reader is in the final section.
- Shared offset — the same number shifts the observer band and the click scroll target, which is why a click always lands exactly where the spy expects the section to start.
- Consumer-owned hash — clicks are intercepted (
preventDefault) and reported throughonNavigate; the component scrolls but never writes to the URL, so routers, analytics and history strategies stay in the app's hands. - Auto-collected outline — with no
headingsprop the component reads the rendered DOM, so MDX/CMS content that already emits heading ids gets a table of contents with zero data plumbing.