Navigation

Table of Contents

Scroll-spy TOC that tracks headings with an IntersectionObserver and slides a measured indicator between entries.

Install

npx shadcn@latest add @paragon/table-of-contents

table-of-contents.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface TableOfContentsItem {
  /** The id of the heading element this entry tracks. */
  id: string;
  label: string;
  /** Nesting level, 1–3. Deeper levels indent. */
  level?: 1 | 2 | 3;
}

export interface TableOfContentsProps extends React.ComponentProps<"nav"> {
  items: TableOfContentsItem[];
  /** Scroll container to observe against; defaults to the viewport. */
  rootRef?: React.RefObject<HTMLElement | null>;
}

/**
 * Scroll-spy table of contents. An IntersectionObserver tracks the headings
 * through a reading band near the top of the scroll root; the active entry's
 * indicator slides between links with a measured translateY at 150ms —
 * smooth but instant-feeling. Clicks scroll smoothly (instant under reduced
 * motion) and lock the indicator to the target so it doesn't hop through
 * intermediate sections.
 */
export function TableOfContents({
  items,
  rootRef,
  className,
  ...props
}: TableOfContentsProps) {
  const [activeId, setActiveId] = React.useState<string | undefined>(
    items[0]?.id,
  );
  const navRef = React.useRef<HTMLElement>(null);
  const indicatorRef = React.useRef<HTMLSpanElement>(null);
  const hasPositioned = React.useRef(false);
  const clickLock = React.useRef<{
    id: string;
    timer: ReturnType<typeof setTimeout>;
  } | null>(null);

  const itemsKey = items.map((item) => item.id).join("");

  React.useEffect(() => {
    const ids = itemsKey.split("").filter(Boolean);
    const headings = ids
      .map((id) => document.getElementById(id))
      .filter((el): el is HTMLElement => el !== null);
    if (headings.length === 0) return;

    const visible = new Set<string>();
    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) visible.add(entry.target.id);
          else visible.delete(entry.target.id);
        }
        const next = ids.find((id) => visible.has(id));
        if (!next) return;
        if (clickLock.current) {
          if (next !== clickLock.current.id) return;
          clearTimeout(clickLock.current.timer);
          clickLock.current = null;
        }
        setActiveId(next);
      },
      { root: rootRef?.current ?? null, rootMargin: "0% 0% -55% 0%" },
    );
    for (const heading of headings) observer.observe(heading);
    return () => observer.disconnect();
  }, [itemsKey, rootRef]);

  React.useEffect(() => {
    return () => {
      if (clickLock.current) clearTimeout(clickLock.current.timer);
    };
  }, []);

  const position = React.useCallback((animate: boolean) => {
    const nav = navRef.current;
    const indicator = indicatorRef.current;
    if (!nav || !indicator) return;
    const active = nav.querySelector<HTMLElement>('[data-active="true"]');
    if (!active) {
      indicator.style.opacity = "0";
      return;
    }
    let top = 0;
    let node: HTMLElement | null = active;
    while (node && node !== nav) {
      top += node.offsetTop;
      node = node.offsetParent instanceof HTMLElement ? node.offsetParent : null;
    }
    if (!animate) indicator.style.transitionProperty = "none";
    indicator.style.opacity = "1";
    indicator.style.height = `${active.offsetHeight}px`;
    indicator.style.transform = `translateY(${top}px)`;
    if (!animate) {
      void indicator.offsetWidth;
      indicator.style.transitionProperty = "";
    }
  }, []);

  React.useLayoutEffect(() => {
    position(hasPositioned.current);
    hasPositioned.current = true;
  }, [position, activeId]);

  React.useEffect(() => {
    const nav = navRef.current;
    if (!nav) return;
    const observer = new ResizeObserver(() => position(false));
    observer.observe(nav);
    return () => observer.disconnect();
  }, [position]);

  const onLinkClick = (
    event: React.MouseEvent<HTMLAnchorElement>,
    id: string,
  ) => {
    event.preventDefault();
    const target = document.getElementById(id);
    if (!target) return;
    setActiveId(id);
    if (clickLock.current) clearTimeout(clickLock.current.timer);
    clickLock.current = {
      id,
      timer: setTimeout(() => {
        clickLock.current = null;
      }, 1000),
    };
    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;
    target.scrollIntoView({
      behavior: reduced ? "auto" : "smooth",
      block: "start",
    });
  };

  return (
    <nav
      ref={navRef}
      aria-label="Table of contents"
      data-slot="table-of-contents"
      className={cn("relative w-full max-w-56", className)}
      {...props}
    >
      <span
        ref={indicatorRef}
        aria-hidden
        className="pointer-events-none absolute top-0 -left-px w-0.5 rounded-full bg-foreground opacity-0 [transition-property:transform,height,opacity] duration-150 ease-out motion-reduce:transition-none"
      />
      <ul className="flex flex-col border-l">
        {items.map((item) => {
          const level = item.level ?? 1;
          return (
            <li key={item.id}>
              <a
                href={`#${item.id}`}
                data-active={item.id === activeId || undefined}
                onClick={(event) => onLinkClick(event, item.id)}
                style={{ paddingLeft: `${12 + (level - 1) * 12}px` }}
                className="flex h-7 items-center rounded-r-md pr-2 text-[13px] text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground data-active:text-foreground"
              >
                <span className="truncate">{item.label}</span>
              </a>
            </li>
          );
        })}
      </ul>
    </nav>
  );
}