useTableOfContents

The useTableOfContents hook allows you to extract headings from MDX or HTML content and track the currently visible section. Ideal for generating floating sidebar navigation in documentation or blog posts.

✅ Features

  • Extracts all h2 and h3 headings with their text and id
  • Observes scroll position to highlight the current section
  • Works with markdown/MDX content rendered via rehype-slug

📦 Usage

1import { useTableOfContents } from '@/hooks/useTableOfContents'
2
3const { toc, activeId } = useTableOfContents()
4
5return (
6    <ul>
7        {toc.map((item) => (
8          <li key={item.id} className={item.id === activeId ? 'font-bold' : ''}>
9            <a href={`#${item.id}`} className="text-sm ml-2 block">
10                {item.title}
11            </a>
12          </li>
13        ))}
14    </ul>
15)

📋 Source Code

This is the full implementation of useTableOfContents

1'use client'
2
3import { useEffect, useState } from 'react'
4
5export interface TOCItem {
6  id: string
7  title: string
8  level: number
9}
10
11export function useTableOfContents(containerSelector = '.prose') {
12  const [toc, setToc] = useState<TOCItem[]>([])
13  const [activeId, setActiveId] = useState<string | null>(null)
14
15  useEffect(() => {
16    const container = document.querySelector(containerSelector)
17    if (!container) return
18
19    const headings = Array.from(
20      container.querySelectorAll('h2, h3')
21    ) as HTMLHeadingElement[]
22
23    const items = headings.map((heading) => ({
24      id: heading.id,
25      title: heading.textContent || '',
26      level: Number(heading.tagName.replace('H', '')),
27    }))
28
29    setToc(items)
30
31    const observer = new IntersectionObserver(
32      (entries) => {
33        const visible = entries.find((entry) => entry.isIntersecting)
34        if (visible?.target) setActiveId((visible.target as HTMLElement).id)
35      },
36      { rootMargin: '0px 0px -80% 0px', threshold: 0.1 }
37    )
38
39    headings.forEach((heading) => observer.observe(heading))
40    return () => observer.disconnect()
41  }, [containerSelector])
42
43  return { toc, activeId }
44}
45