Building a Table of Contents Component

June 29, 2025 (1mo ago)8 min read

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:

  1. Automatic Generation: It had to generate itself from the article's headings without manual configuration.
  2. Active State Tracking: It should always know where the reader is and highlight the current section.
  3. Smart Auto-Scrolling: The TOC should automatically scroll to keep the active item visible, especially for longer articles.
  4. Aesthetically Pleasing: It should look good and have subtle, meaningful animations.
  5. 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.

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 ids 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:

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.