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
rootMarginvalue '-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.