If you're reading this, take a look to your right (or bottom on mobile). You'll see a Table of Contents that's a bit more... alive. It tracks your scroll position, automatically scrolls to keep the active section in view, and even offers a little preview on hover. It's a small component, but it's packed with details that I think will make for a much better reading experience for future posts.
In this post, I want to break down how I built it. We'll go from the essential data pipeline that makes it possible, all the way to the intelligent auto-scrolling logic that ensures every section is accessible.
Why?
Most Table of Contents components are simple. They render a list of links, you click one, and you jump to a section. That works, but I wanted something that felt more integrated and intelligent. My goals were:
- Automatic Generation: It had to generate itself from the article's headings without manual configuration.
- Active State Tracking: It should always know where the reader is and highlight the current section.
- Smart Auto-Scrolling: The TOC should automatically scroll to keep the active item visible, especially for longer articles.
- Aesthetically Pleasing: It should look good and have subtle, meaningful animations.
- Responsive: It needed a great desktop and mobile experience.
Part 1: The Foundation - Anchors in the Markdown
Before we can even think about a React component, we have a fundamental problem to solve: our ToC needs to link to specific headings in the article. This means each heading (<h2>
, <h3>
, etc.) needs a unique id
attribute.
Doing this manually for every heading would be a nightmare. The solution lies in my content pipeline, which uses unified
with remark
and rehype
to process markdown. By adding two plugins, we can automate this entire process.
rehype-slug
: This plugin automatically generates a URL-friendlyid
(a "slug") for each heading.rehype-autolink-headings
: This one takes it a step further and wraps the heading in an anchor tag (<a>
) that links to its own newid
, making it easy to link directly to any section.
Here's an example of how they are configured:
// src/data/blog.ts
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypePrettyCode from "rehype-pretty-code";
// ... other imports
export async function markdownToHTML(markdown: string) {
const p = await unified()
.use(remarkParse)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeSlug) // First, generate the IDs
.use(rehypeAutolinkHeadings, { // Then, create the links
properties: {
className: ["anchor"],
},
})
.use(rehypePrettyCode, {
// ... options
})
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
return p.toString();
}
With this setup, a simple markdown heading like # Part 1: The Foundation
is automatically transformed into this HTML:
<h2 id="part-1-the-foundation">
<a class="anchor" href="#part-1-the-foundation"></a>
Part 1: The Foundation
</h2>
Now our React component will have reliable id
s to target.
Part 2: The Component - Finding the Headings
With our HTML structure sorted, we can build the React component. The first step is to find all the heading elements on the page when the component mounts.
A useEffect
hook is perfect for this. It runs on the client, queries the DOM for the article
content, and then finds all heading elements up to a specified maxLevel
.
// src/components/table-of-contents.tsx
// ...
const [tocItems, setTocItems] = useState<TocItem[]>([]);
useEffect(() => {
const article = document.querySelector('article');
if (!article) return;
// Only select headings up to maxLevel (e.g., h1, h2, h3)
const selector = Array.from({ length: maxLevel }, (_, i) => `h${i + 1}`).join(', ');
const headings = article.querySelectorAll(selector);
let items: TocItem[] = [];
headings.forEach((heading, index) => {
const element = heading as HTMLElement;
// Ensure heading has an ID (with a fallback)
if (!element.id) {
element.id = generateSlug(element.textContent || '', index);
}
items.push({
id: element.id,
title: element.textContent || '',
level: parseInt(element.tagName.charAt(1)),
element,
offsetTop: element.offsetTop
});
});
setTocItems(items);
}, [maxLevel]); // Reruns if maxLevel changes
This gives us a tocItems
state array, where each item is an object containing the id
, title
, level
, and the DOM element itself. We now have everything we need to render the list of links.
Part 3: The Brains - Active Heading Detection
This is where things get interesting. How do we know which heading is "active" as the user scrolls?
My approach uses a "reference line" near the top of the viewport. The last heading to cross above this line is considered the active one. This feels more natural than just using the very top of the viewport, as it gives the new section a moment to establish itself before the component updates.
The core logic is in the findActiveHeading
function, which is called by a scroll event listener.
// src/components/table-of-contents.tsx
const findActiveHeading = useCallback(() => {
if (tocItems.length === 0) return null;
const scrollTop = window.pageYOffset;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Check if we're at the very top of the page
if (scrollTop < 50) {
return tocItems[0];
}
// Check if we're at the bottom of the page
const isAtBottom = scrollTop + windowHeight >= documentHeight - 2;
if (isAtBottom) {
return tocItems[tocItems.length - 1];
}
// Calculate the reference line at 10% of viewport height
const referenceLine = scrollTop + windowHeight * 0.1;
// Find the last heading that's above the reference line
let activeHeading = tocItems[0];
for (let i = tocItems.length - 1; i >= 0; i--) {
const item = tocItems[i];
const elementTop = item.offsetTop || item.element?.offsetTop || 0;
if (elementTop <= referenceLine) {
activeHeading = item;
break;
}
}
return activeHeading;
}, [tocItems]);
This function also contains extra logic to handle edge cases, like being at the very top or bottom of the page, ensuring the first or last item is correctly selected.
The scroll handler that calls this is optimized with requestAnimationFrame
to prevent performance issues during rapid scrolling.
Part 4: The Polish - Smart Auto-Scrolling
Instead of using complex transforms to center items, I opted for a more robust scrollable container approach. The TOC has a maximum height and becomes scrollable when there are many items, but the real magic is in the intelligent auto-scrolling logic.
Keeping the Active Item in View
When the active heading changes as the user scrolls, the TOC automatically scrolls to keep that item visible. This is handled by the scrollTocToActiveItem
function:
// src/components/table-of-contents.tsx
const scrollTocToActiveItem = useCallback((itemId: string) => {
if (isMobile || !tocScrollContainerRef.current) return;
const activeButton = itemRefs.current.get(itemId);
if (!activeButton) return;
const container = tocScrollContainerRef.current;
const containerRect = container.getBoundingClientRect();
const buttonRect = activeButton.getBoundingClientRect();
// Calculate if the button is outside the visible area
const isAboveView = buttonRect.top < containerRect.top;
const isBelowView = buttonRect.bottom > containerRect.bottom;
if (isAboveView || isBelowView) {
// ... intelligent positioning logic
const activeIndex = tocItems.findIndex(item => item.id === itemId);
const totalItems = tocItems.length;
let targetScrollTop;
if (activeIndex === 0) {
// First item - scroll to top
targetScrollTop = 0;
} else if (activeIndex >= totalItems - 3) {
// Last few items - scroll to bottom to ensure all remaining items are visible
targetScrollTop = containerScrollHeight - containerHeight;
} else {
// Center the item with smart positioning
targetScrollTop = buttonOffsetTop - (containerHeight / 2) + (buttonHeight / 2);
}
// Smooth scroll to the target position
container.scrollTo({
top: targetScrollTop,
behavior: 'smooth'
});
}
}, [isMobile, tocItems]);
The key insight here is the special handling for the last few items. When you're reading near the end of an article, instead of centering the active item (which might hide the remaining sections), the TOC scrolls to show all remaining items at the bottom. This ensures you never lose track of where you are in the content structure.
Animation and Polish
A static list is functional, but animation adds a layer of polish that makes the component feel truly interactive. I used framer-motion
for this.
On desktop, items have a subtle cascade animation when they first appear:
// Items start hidden and cascade in
const delay = (index - 1) * 0.08;
const itemStyle = {
opacity: animationStarted ? 1 : 0,
transform: animationStarted ? 'translateY(0)' : 'translateY(-20px)',
transition: `opacity 0.4s ease ${delay}s, transform 0.6s cubic-bezier(0.4, 0, 0.2, 1) ${delay}s`,
};
Mobile Experience
On mobile, the ToC transforms into a bottom panel that slides up, triggered by a button in the nav bar. AnimatePresence
from Framer Motion makes the enter and exit animations trivial to implement, creating a smooth and non-disruptive experience.
Final Touches
A few other details bring it all together:
- Click-to-Scroll: Uses
window.scrollTo({ behavior: 'smooth' })
for that buttery smooth navigation. - Heading Highlight: When you click a ToC item, the corresponding heading on the page briefly flashes with a highlight, giving you a clear visual confirmation of where you've landed.
- Hover Previews: On desktop, hovering over a ToC item shows you the first few lines of that section, helping you decide where to go next.
And that's a glimpse into the process of building the ToC component! Simple UI elements can and should become a rich, interactive experience with a bit of extra thought and polish.