Building a Table of Contents with IntersectionObserver

· 10 min read · JavaScript

Build a performant table of contents with scroll highlighting using IntersectionObserver instead of scroll events.

Building a Table of Contents with IntersectionObserver

Documentation sites and technical blogs often feature a sidebar that highlights the current section as you scroll. This scrollspy pattern improves navigation and helps readers track their position in long articles.

The modern way to build this uses the IntersectionObserver API instead of scroll event listeners. This approach is more performant and easier to maintain.

Problem

Traditional scrollspy implementations use scroll events:

window.addEventListener('scroll', () => {
  // Calculate which section is visible
  // Update active state
});

This causes several issues:

  • Scroll events fire rapidly, causing performance problems
  • Manual threshold calculations are error-prone
  • Code becomes complex with multiple edge cases
  • Difficult to handle dynamic content

Why It Happens

Scroll events fire on every pixel of scroll movement. Even with debouncing or throttling, you are still running calculations frequently. The browser must interrupt the main thread to handle these events, potentially causing scroll jank.

IntersectionObserver solves this by letting the browser handle visibility detection asynchronously, only notifying your code when elements actually enter or leave the viewport.

Solution

Use IntersectionObserver to detect when headings enter the viewport, then update the active state in your table of contents.

Implementation

First, extract headings from your content:

interface TocItem {
  id: string;
  text: string;
  level: number;
}

function extractHeadings(content: string): TocItem[] {
  const headingRegex = /^(#{2,3})\s+(.+)$/gm;
  const headings: TocItem[] = [];
  let match;

  while ((match = headingRegex.exec(content)) !== null) {
    const level = match[1].length;
    const text = match[2].trim();
    const id = text
      .toLowerCase()
      .replace(/[^a-z0-9\s-]/g, '')
      .replace(/\s+/g, '-');

    headings.push({ id, text, level });
  }

  return headings;
}

Next, create the observer:

function createScrollspy(
  headings: TocItem[],
  onActiveChange: (id: string) => void
): IntersectionObserver {
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          onActiveChange(entry.target.id);
        }
      });
    },
    {
      // Trigger when heading is 20% from top
      rootMargin: '-20% 0px -60% 0px',
      threshold: 0
    }
  );

  headings.forEach(({ id }) => {
    const element = document.getElementById(id);
    if (element) {
      observer.observe(element);
    }
  });

  return observer;
}

TIP: The rootMargin value '-20% 0px -60% 0px' creates a detection zone in the top 40% of the viewport. This makes headings activate when they are in a comfortable reading position, not just when they touch the top edge.

Example

Complete React component:

import { useEffect, useState } from 'react';

interface TableOfContentsProps {
  content: string;
}

export function TableOfContents({ content }: TableOfContentsProps) {
  const [headings, setHeadings] = useState<TocItem[]>([]);
  const [activeId, setActiveId] = useState<string>('');

  useEffect(() => {
    setHeadings(extractHeadings(content));
  }, [content]);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setActiveId(entry.target.id);
            // Sync URL hash
            window.history.replaceState(null, '', `#${entry.target.id}`);
          }
        });
      },
      { rootMargin: '-20% 0px -60% 0px', threshold: 0 }
    );

    headings.forEach(({ id }) => {
      const element = document.getElementById(id);
      if (element) observer.observe(element);
    });

    return () => observer.disconnect();
  }, [headings]);

  const handleClick = (id: string) => {
    const element = document.getElementById(id);
    if (element) {
      element.scrollIntoView({ behavior: 'smooth' });
    }
  };

  return (
    <nav className="toc">
      <h2>On this page</h2>
      <ul>
        {headings.map((heading) => (
          <li key={heading.id}>
            <button
              onClick={() => handleClick(heading.id)}
              className={activeId === heading.id ? 'active' : ''}
              style={{ paddingLeft: heading.level === 3 ? '1rem' : 0 }}
            >
              {heading.text}
            </button>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Common Mistakes

Wrong rootMargin values

Using rootMargin: '0px' makes headings activate only when they touch the exact top of the viewport. This feels unnatural because readers typically look at content below the fold.

// Too precise - feels wrong
{ rootMargin: '0px' }

// Better - activates in reading zone
{ rootMargin: '-20% 0px -60% 0px' }

Not handling smooth scroll

When users click a TOC link, smooth scrolling triggers multiple intersection events. The active state flickers through several headings.

Solution: Temporarily disable the observer during smooth scroll:

const handleClick = (id: string) => {
  observer.disconnect();
  
  const element = document.getElementById(id);
  if (element) {
    element.scrollIntoView({ behavior: 'smooth' });
    setActiveId(id);
    
    // Re-enable after scroll completes
    setTimeout(() => {
      headings.forEach(({ id }) => {
        const el = document.getElementById(id);
        if (el) observer.observe(el);
      });
    }, 1000);
  }
};

Missing cleanup

Always disconnect the observer when the component unmounts:

useEffect(() => {
  const observer = new IntersectionObserver(...);
  // ... observe elements
  
  return () => observer.disconnect(); // Critical
}, [headings]);

WARNING: Failing to disconnect creates memory leaks and can cause errors if the observed elements are removed from the DOM.

Conclusion

IntersectionObserver provides a performant foundation for scrollspy functionality. Configure rootMargin to match natural reading position, handle smooth scroll transitions gracefully, and always clean up observers on unmount.