Skip to content

TableOfContents

SM · 950B gzip budget

A jump-list that tracks the visible heading via IntersectionObserver.

Category
Navigation
Budget
sm, single-purpose control
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/table-of-contents.json

Copies the source into your project. Pulls in 2 shared registry items: sanitize, cn.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { useEffect, useState } from "react";
import { sanitizeHref } from "../lib/sanitize";
import { cn } from "../lib/cn";

export interface TocEntry {
  id: string;
  label: string;
  depth?: number;
}

export interface TableOfContentsProps {
  entries: TocEntry[];
  className?: string;
}

/**
 * A jump-list that tracks and marks the currently visible heading with
 * `aria-current="location"` via `IntersectionObserver` — cheaper than a
 * scroll listener, and inert (no work at all) until a watched heading
 * actually crosses the viewport.
 */
export function TableOfContents({ entries, className }: TableOfContentsProps) {
  const [activeId, setActiveId] = useState<string | null>(null);

  useEffect(() => {
    const elements = entries.map((e) => document.getElementById(e.id)).filter((el): el is HTMLElement => !!el);
    if (elements.length === 0) return;

    const observer = new IntersectionObserver(
      (observerEntries) => {
        const visible = observerEntries.filter((e) => e.isIntersecting);
        if (visible.length > 0) setActiveId(visible[0].target.id);
      },
      { rootMargin: "-20% 0px -70% 0px" },
    );

    elements.forEach((el) => observer.observe(el));
    return () => observer.disconnect();
  }, [entries]);

  return (
    <nav aria-label="Table of contents" className={className}>
      <ul className="flex flex-col gap-1 text-sm">
        {entries.map((entry) => (
          <li key={entry.id} style={{ paddingInlineStart: `${(entry.depth ?? 0) * 12}px` }}>
            <a
              href={sanitizeHref(`#${entry.id}`)}
              aria-current={activeId === entry.id ? "location" : undefined}
              className={cn(
                "block py-1",
                activeId === entry.id ? "font-medium text-blue-700 dark:text-blue-400" : "text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100",
              )}
            >
              {entry.label}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation