Table of Contents
Auto-scrolling table of contents with intersection observer for active section.
docs/table-of-contents.tsx
"use client"
import { cn } from '../utils'
import { useEffect, useMemo, useState } from 'react'
import { TableOfContentsIcon } from 'lucide-react'
export interface TocItem {
id: string
title: string
level?: number
children?: TocItem[]
}
interface TableOfContentsProps {
/** Markdown content to extract headings from */
content?: string
/** Pre-defined items (alternative to content extraction) */
items?: TocItem[]
/** Optional header slot (e.g., language selector) */
header?: React.ReactNode
/** Optional footer slot (e.g., link to full docs) */
footer?: React.ReactNode
className?: string
}
// Extract headings from markdown content (h2 and h3 only, h1 is page title)
// Nests h3s under their preceding h2
function extractHeadings(content: string): TocItem[] {
const result: TocItem[] = []
const lines = content.split('\n')
let currentH2: TocItem | null = null
for (const line of lines) {
const match = line.match(/^(#{2,3})\s+(.+)$/)
if (match) {
const level = match[1].length
const title = match[2].trim()
// Create a URL-safe ID from the heading text
const id = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
if (level === 2) {
currentH2 = { id, title, level, children: [] }
result.push(currentH2)
} else if (level === 3) {
const item = { id, title, level }
if (currentH2) {
currentH2.children = currentH2.children || []
currentH2.children.push(item)
} else {
// h3 without preceding h2, add to root
result.push(item)
}
}
}
}
return result
}
// Flatten nested items for intersection observer
function flattenItems(items: TocItem[]): TocItem[] {
return items.flatMap(item => [item, ...(item.children || [])])
}
export function TableOfContents({ content, items, header, footer, className }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>('')
// Use provided items or extract from markdown
const tocItems = useMemo(() => {
if (items) return items
if (content) return extractHeadings(content)
return []
}, [items, content])
// Flatten for observer
const allItems = useMemo(() => flattenItems(tocItems), [tocItems])
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id)
}
})
},
{
rootMargin: '-80px 0px -80% 0px',
threshold: 0,
}
)
// Observe all heading elements
allItems.forEach(({ id }) => {
const element = document.getElementById(id)
if (element) {
observer.observe(element)
}
})
return () => observer.disconnect()
}, [allItems])
if (tocItems.length === 0) {
return null
}
const renderItem = (item: TocItem, isChild = false) => {
const isActive = activeId === item.id
const level = item.level || (isChild ? 3 : 2)
return (
<li key={item.id}>
<a
href={`#${item.id}`}
onClick={(e) => {
e.preventDefault()
const element = document.getElementById(item.id)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
setActiveId(item.id)
}
}}
className={cn(
'flex items-center text-[13px] py-1 -ml-px border-l transition-colors lowercase',
!isChild && 'pl-3 font-medium',
isChild && 'pl-4 text-xs',
isActive
? 'border-brand-pink text-brand-pink'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
)}
>
{isChild && (
<span className={cn(
"mr-1.5 text-[10px] font-mono",
isActive ? "text-brand-pink" : "text-muted-foreground/50"
)}>
└
</span>
)}
{item.title}
</a>
{item.children && item.children.length > 0 && (
<ul className="space-y-0.5">
{item.children.map(child => renderItem(child, true))}
</ul>
)}
</li>
)
}
return (
<nav className={cn('relative', className)}>
{header}
<div className="flex items-center gap-2 mb-3">
<TableOfContentsIcon className="h-4 w-4 text-muted-foreground" />
<p className="text-xs font-bold text-muted-foreground">
on this page
</p>
</div>
<ul className="space-y-1 border-l border-border">
{tocItems.map(item => renderItem(item))}
</ul>
{footer}
</nav>
)
}
- Where it goes
- data-table
- Use cases
- dashboard