Skip to main content

Building an Auto-Generated Table of Contents with Scroll Spy

A clean desk with an open notebook showing a table of contents
Apr 4, 20263 min readReact, Accessibility, MDX

The problem

Long-form MDX posts on this portfolio, case studies running 1,500+ words across a handful of sections, gave readers no way to see where they were or jump to a specific section; the browser's scroll bar was the only hint of progress.

So I needed a table of contents that:

  1. Self-generates from the MDX content (no manual configuration per post)
  2. Tracks reading position in real time
  3. Works on both desktop and mobile with appropriate UI patterns
  4. Respects accessibility (focus management, reduced motion, keyboard nav)

Heading extraction with useHeadings

The first challenge was getting heading data out of rendered MDX without asking authors to maintain a separate outline by hand. The solution was to scan the DOM after render.

function useHeadings() {
  const [headings, setHeadings] = useState<Heading[]>([]);
 
  useEffect(() => {
    queueMicrotask(() => {
      const els = document.querySelectorAll('article h2, article h3');
      const items = Array.from(els)
        .filter(el => el.id)
        .map(el => ({
          id: el.id,
          text: el.textContent ?? '',
          level: Number(el.tagName[1]),
        }));
      setHeadings(items);
    });
  }, []);
 
  return headings;
}

Key decisions here:

  • h2 and h3 only: h1 is the page title (already visible), and h4+ adds noise to navigation
  • queueMicrotask: ensures the DOM scan happens after MDX components have fully rendered, avoiding race conditions with hydration
  • el.id filter: MDX heading plugins auto-generate IDs; headings without IDs are excluded since they can't be scroll targets

Scroll spy with IntersectionObserver

Rather than a scroll listener polling getBoundingClientRect() on every frame, I let IntersectionObserver tell me when headings enter or leave the viewport:

useEffect(() => {
  const observer = new IntersectionObserver(
    entries => {
      for (const entry of entries) {
        if (entry.isIntersecting) {
          setActiveId(entry.target.id);
        }
      }
    },
    { rootMargin: '-80px 0px -60% 0px' }
  );
 
  headings.forEach(({ id }) => {
    const el = document.getElementById(id);
    if (el) observer.observe(el);
  });
 
  return () => observer.disconnect();
}, [headings]);

The rootMargin is the secret sauce:

  • -80px top accounts for the sticky nav bar height
  • -60% bottom means only the top 40% of the viewport triggers activation, so the active heading matches where the reader's eyes actually are, not whatever heading happens to be at the bottom of the screen

This costs basically nothing at runtime: no requestAnimationFrame loops, no throttled scroll handlers, just the browser's compositor thread doing the intersection math natively.

Responsive design: sidebar vs. bottom sheet

Desktop and mobile want genuinely different TOC patterns:

Desktop (lg+): A sticky sidebar pinned to the left of the article content. It stays visible as you scroll, showing your position the whole way down.

function DesktopToc({ headings, activeId }: TocProps) {
  return (
    <nav aria-label='Table of contents' className='sticky top-24'>
      <TocList headings={headings} activeId={activeId} />
    </nav>
  );
}

Mobile: A floating action button (FAB) in the bottom-left corner. Tapping it opens a bottom sheet that slides up to 60% of the viewport height.

The mobile sheet required several accessibility considerations:

  • Focus trap: tabbing cycles within the sheet while it's open
  • Body scroll lock: overflow: hidden on the body prevents background scrolling
  • Escape key: closes the sheet
  • Backdrop click: closes the sheet
  • Reduced motion: transitions respect prefers-reduced-motion
// Focus trap: cycle focus within the sheet
function handleKeyDown(e: KeyboardEvent) {
  if (e.key === 'Escape') {
    setOpen(false);
    return;
  }
  if (e.key === 'Tab') {
    // Cycle between focusable elements in the sheet
    const focusable = sheetRef.current?.querySelectorAll(
      'button, [href], [tabindex]:not([tabindex="-1"])'
    );
    // ...trap logic
  }
}

Both implementations share the same TocList component; the only thing that differs is the container and the trigger mechanism.

Smooth scrolling with accessibility

When a user clicks a TOC link the page scrolls to the target heading, but smooth scrolling can be genuinely disorienting for people with vestibular disorders:

function scrollToHeading(id: string) {
  const el = document.getElementById(id);
  if (!el) return;
 
  const prefersReducedMotion = window.matchMedia(
    '(prefers-reduced-motion: reduce)'
  ).matches;
 
  el.scrollIntoView({
    behavior: prefersReducedMotion ? 'instant' : 'smooth',
    block: 'start',
  });
}

That single check respects the user's OS-level motion preference, with no custom setting to wire up.

Integration with the two-column layout

The TOC lives inside PostBody, which uses a flex layout to position it:

<div className='flex gap-10'>
  {/* Left: sticky TOC (desktop only) */}
  <div className='hidden lg:flex flex-col gap-6 w-48 shrink-0'>
    <TableOfContents desktop />
  </div>
 
  {/* Right: metadata + article */}
  <div className='flex-1 min-w-0'>
    {/* Date, tags, reading time grid */}
    {children}
  </div>
</div>;
 
{
  /* Mobile TOC must be OUTSIDE the hidden container */
}
<TableOfContents mobile />;

One subtle but critical detail: the mobile TOC FAB is rendered outside the hidden lg:flex container. An earlier version of mine nested it inside, which meant the FAB was hidden on mobile, which is precisely when it was needed most.

Results

  • 149 lines of component code (single file, highly modular)
  • Zero runtime overhead: native IntersectionObserver, no polling
  • Fully accessible: focus trap, keyboard nav, reduced motion support
  • Self-maintaining: authors write MDX with headings, TOC generates itself
  • Works across all content types (projects, experience, blog) automatically through the shared PostBody component

The lesson

The best UI affordances are usually the ones the browser already hands you. IntersectionObserver, prefers-reduced-motion, native scrollIntoView, and auto-generated heading IDs from the MDX plugin meant I wrote 149 lines of glue code and got an accessible, zero-cost TOC with scroll spy out of it. Before you reach for a throttled scroll listener or a custom animation timing function, check whether the platform already solved it; it usually did.