Navigation

TOC Scrollspy

An article outline that follows the reader — headings are watched with one IntersectionObserver and a rail slides to the current section.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export interface TocHeading {
  id: string
  text: string
  /** 2 sits flush against the rail; 3 (or deeper) indents one step. */
  level: number
}

export interface TocScrollspyProps extends React.HTMLAttributes<HTMLElement> {
  /** Explicit outline. Omit to collect every h2/h3 that has an id from the scope below. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/toc-scrollspy.json

Prompt

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 through onNavigate; 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 headings prop the component reads the rendered DOM, so MDX/CMS content that already emits heading ids gets a table of contents with zero data plumbing.

On This Page