Why I spent weeks perfecting a TOC component
A deep dive into building a Table of Contents component with sophisticated visual debugging and pixel-perfect active state detection.

Table of contents are one of those things you never really notice—until they’re missing. Then suddenly, a blog post feels incomplete.
When I first built mine, I fell into the same trap as everyone else: optimizing for SEO. There are hundreds of TOC designs out there, and most follow the same playbook—show everything, maintain hierarchy, make sure search engines can crawl every heading.
But here’s what I realized: the main purpose of a TOC isn’t structure—it’s skimming. It’s like flipping a book over to read the synopsis before deciding if it’s worth your time. Blog readers do the same thing. They want to know what they’re getting into without committing to the full read.
That’s when I started questioning the conventional wisdom. Sure, for SEO it’s better to display the full hierarchical structure. But honestly? I don’t like that approach. When I’m skimming an article, I’m not looking for perfect h2-h3-h4 organization. I just want a bulleted list of what’s inside—quick, scannable, no cognitive overhead.
So I built mine differently. I extract all headings but present them as a flat list—no visual hierarchy, just the order they appear. And I only show 10 at a time. That number gives you a solid preview of what’s coming without feeling overwhelming or intimidating.
This design choice led me down a rabbit hole that started as “let’s make a simple TOC” and ended with one of the more interesting components I’ve built.
TL;DR: I built a preview of this TOC component in this CodeSandbox. Toggle the debug overlay (Ctrl+Shift+D) to see the focus region detection in action.
The Problem with “Good Enough”
Most Table of Contents components follow the same pattern: detect when a heading enters or exits the viewport, highlight the corresponding nav item. Simple, functional, boring.
Mine started that way too. A basic Intersection Observer watching headings, some CSS transitions for the active state. It worked fine until real users started scrolling through long articles.
The feedback was consistent: the active states felt “too eager.” Headings would highlight before their content was actually visible, creating a disconnect between what users were reading and what the TOC claimed they should be reading.
That’s when I realized the standard approach—treating each heading as a point in space—was fundamentally flawed. Reading isn’t about headings; it’s about content blocks. A heading represents the start of a section, not the entire thing.
The Focus Region
The solution came from rethinking how we define “active.” Instead of asking “is this heading visible?” I started asking “what content is the user actually focused on right now?”
I introduced what I call a focus region—the middle 40% of the viewport (30% from top, 30% from bottom). This is where focused reading happens. The top and bottom are transition zones where users are scrolling past content or anticipating what’s coming.
const focusTop = viewportTop + viewportHeight * 0.3;
const focusBottom = viewportTop + viewportHeight * 0.7;
But here’s where it gets interesting: I don’t just check if a heading is in this region. I calculate how much of each content block (heading + content until next heading) overlaps with the focus region.
const calculateBlockIntersection = (block, viewportTop, viewportHeight) => {
const focusTop = viewportTop + viewportHeight * 0.3;
const focusBottom = viewportTop + viewportHeight * 0.7;
const focusHeight = focusBottom - focusTop;
const intersectionTop = Math.max(block.startY, focusTop);
const intersectionBottom = Math.min(block.endY, focusBottom);
const intersectionHeight = Math.max(0, intersectionBottom - intersectionTop);
return intersectionHeight / focusHeight; // Coverage percentage
};
When multiple blocks are in the focus region, the one with the largest coverage wins. When coverage is similar, scroll direction becomes the tiebreaker. The result feels natural—the TOC highlights what you’re actually reading, not just what’s technically visible.
Handling Consecutive Headings
One caveat I discovered: sometimes you have consecutive headings without meaningful content between them. Consider this markdown structure:
# Main Feature Overview
## Implementation Details
### Technical Specifications
### Performance Considerations
Showing all these in the TOC creates visual noise and jerky transitions. The algorithm filters out smaller headings that follow larger ones when there’s no substantial content between them. This keeps the TOC clean and makes scrolling feel smoother.
The Art of Practical Approximation
The 40% middle region isn’t scientifically precise—it’s a practical compromise.
In reality, reading patterns vary dramatically based on screen size, content type, and scroll direction. Users often focus on the middle initially, then read toward the bottom without scrolling (what I call “scroll fatigue”). When they finally do scroll, they’ve already consumed content outside the focus zone.
Direction matters too. When scrolling up to find a reference, the entire logic reverses. Ideally, you’d account for scroll velocity, reading patterns around anchor elements (headings, quotes, code blocks), screen size adaptations, and even eye-tracking data (this is a whole other rabbit hole).
But optimizing for every variable will kill your sanity and most-likely wont make a noticeable difference. Perfect is truly the enemy of productivity.
Debug-Driven Development
Building this logic was tricky. How do you debug viewport calculations, content block boundaries, and intersection math? Traditional console logging felt inadequate for spatial problems.
So I built a visual debug system. Color-coded overlays show:
- The focus region as a red transparent band
- Content blocks as colored rectangles
- Active intersections highlighted in different colors
- Real-time debug info panel
This debug system became essential for development. I could immediately see when my algorithm was making wrong decisions and iterate quickly. It’s also incredibly useful for explaining the logic to others—visual proof is hard to argue with.
The DOM Positioning Trap
One nasty bug taught me about DOM positioning accuracy. Initially, I used offsetTop
to calculate block boundaries. Everything seemed fine until I saw weird active states that didn’t match the debug visualization.
The issue: offsetTop
includes positioning from unrelated page elements. With complex CSS layouts, this introduced significant errors. Switching to getBoundingClientRect().top + window.scrollY
gave pixel-perfect accuracy.
// Wrong: Includes unrelated element offsets
const startY = heading.offsetTop;
// Right: Accurate position calculation
const headingRect = heading.getBoundingClientRect();
const startY = headingRect.top + window.scrollY;
This change fixed boundary calculations and made the debug visualization match reality. Small detail, big impact.
Performance Without Compromise
Scroll events fire constantly, so performance matters. The solution uses several optimization strategies:
- Debounced scroll handlers (50ms) for active state detection
- Memoized calculations for expensive DOM operations
- Passive event listeners to avoid blocking scroll
- React.memo and strategic re-rendering prevention
The visual debug updates run on every scroll for smooth overlays, while the actual logic runs debounced. This gives you immediate visual feedback during development without sacrificing production performance.
What I Learned
Building this TOC gave me some insights that apply beyond simple navigation components:
Visual debugging is a superpower. For spatial or timing-based problems, seeing is understanding. Custom debug visualizations often solve problems faster than traditional debugging.
Browser positioning APIs can be tricky. Complex CSS layouts often cause traditional offset calculations to fail. Trust getBoundingClientRect
for reliable positioning data.
Performance and features aren’t mutually exclusive. With proper optimization strategies, you can build sophisticated interactions without sacrificing responsiveness.
Try It Yourself
You can explore the full implementation in this CodeSandbox. Toggle the debug overlay (Ctrl+Shift+D) to see the focus region detection in action.
Full disclosure: This sandbox is a quick itteration that I genearted with AI only for this blog post.
The code has lots of comments, and the debug system makes it easy to understand how each piece works. If you build your own version, I’d love to hear about your improvements or alternative approaches.
Sometimes the best development happens in the margins—those side quests that start small and end up teaching you more than the main project. This TOC component was one of those for me. What started as a simple navigation aid became an exercise in user experience, algorithm design, and visual debugging.
Not bad for a “mundane” component.
This article is part of my development journey series. If you found it useful, consider subscribing to my newsletter where I share more insights from building with modern web technologies.