toc

ui

Table of contents component with scroll-spy highlighting. Shows heading hierarchy extracted from MDX content.

document0/tocv0.1.0react, next

Preview

Installation

$npx @document0/cli add document0/toc

Usage

import { TableOfContents } from "./components/document0/toc";

// Example usage in your layout or page:
<TableOfContents />

Source

After installation, this lives at components/document0/toc/TableOfContents.tsx and you can modify it however you like.

"use client";

import { useEffect, useState, useCallback } from "react";

interface TocEntry {
  id: string;
  text: string;
  depth: number;
}

/**
 * Table of contents with scroll-spy highlighting.
 *
 * Usage:
 * ```tsx
 * import { TableOfContents } from "@/components/document0/toc/TableOfContents";
 * import { processMdx } from "@document0/mdx";
 *
 * // In your page component:
 * const { toc } = await processMdx(content);
 *
 * return (
 *   <aside>
 *     <TableOfContents toc={toc} />
 *   </aside>
 * );
 * ```
 */
export function TableOfContents({ toc }: { toc: TocEntry[] }) {
  const [activeId, setActiveId] = useState<string>("");

  const updateActive = useCallback(() => {
    const atBottom =
      window.innerHeight + window.scrollY >= document.body.scrollHeight - 50;
    if (atBottom && toc.length > 0) {
      setActiveId(toc[toc.length - 1].id);
      return;
    }

    const threshold = 80;
    let current = toc.length > 0 ? toc[0].id : "";
    for (const { id } of toc) {
      const el = document.getElementById(id);
      if (!el) continue;
      if (el.getBoundingClientRect().top <= threshold) {
        current = id;
      }
    }
    setActiveId(current);
  }, [toc]);

  useEffect(() => {
    updateActive();
    window.addEventListener("scroll", updateActive, { passive: true });
    return () => window.removeEventListener("scroll", updateActive);
  }, [updateActive]);

  return (
    <div className="sticky top-20">
      <p className="mb-3 text-xs font-semibold uppercase tracking-wider text-zinc-500">
        On this page
      </p>
      <ul className="space-y-1">
        {toc.map((entry) => (
          <li key={entry.id} style={{ paddingLeft: `${(entry.depth - 1) * 12}px` }}>
            <a
              href={`#${entry.id}`}
              className={[
                "block py-0.5 text-sm transition-colors",
                activeId === entry.id
                  ? "text-sky-400 font-medium"
                  : "text-zinc-500 hover:text-zinc-300",
              ].join(" ")}
            >
              {entry.text}
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Tags

navigationtocheadingsreact