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…
- Registry dependencies
- utils, use-prefers-reduced-motion
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
- Shared primitive: use-prefers-reduced-motion (installed automatically via
registryDependencies) — its one-shot getPrefersReducedMotion() read at click
time picks the scroll behavior. If rebuilding without it: a
useSyncExternalStore over matchMedia("(prefers-reduced-motion: reduce)") with a
"change" listener and a server snapshot of false (for one-shot reads: read
.matches at the moment of the event).
- 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, and
keeps the outline in sync as that subtree changes.
- 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.
- Collect again when the scope changes: content often lands after mount
(Suspense, streamed MDX, a client fetch) or is swapped under a persistent
layout by a client-side navigation, and a one-shot collect would render
nothing forever or keep a stale outline. A MutationObserver on the container
(or document.body) with { childList, subtree, attributeFilter: ["id"] },
coalesced to one pass per requestAnimationFrame, re-collects; setState only
when the id / text / level list or the heading NODES changed (it fires for
every unrelated DOM change in scope). A new collected array also re-arms the
spy, because two routes can share an id (#overview) behind fresh elements.
- 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.
- Clamp the band: its height is 0.3 × rootHeight − offset, which goes to zero or
negative once offset reaches 30% of the root (the default 80px against a
266px scroll box). Per spec nothing intersects a negative-height root (Chromium
happens to clamp it to a line; do not rely on that) and the highlight freezes.
When that height would drop under 24px, switch to px margins that keep a 24px
band starting at min(offset, rootHeight − 24).
- 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 (rebuilding the observer when the resize
moved the band across that clamp); 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. Modified or non-primary
clicks (meta / ctrl / shift / alt, button !== 0, or already defaultPrevented)
are left to the browser, so "open in new tab" still works. A plain click
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 scoped to the container
([id="..."], so a duplicate id elsewhere on the page cannot win), and pass the
id through CSS.escape — a `"` or `\` in it would otherwise make querySelector
throw inside the effect.
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. A MutationObserver re-reads it when the content changes, so an article that streams in late or is swapped by a client-side navigation gets a fresh outline.