shadimage

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