Table of Contents
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; reaching the very
 * bottom pins the last entry so short final sections still activate. The
 * active entry carries aria-current="location" and a measured indicator
 * slides between links with a translateY at 150ms — smooth but
 * instant-feeling — fading in place on first appearance. Clicks scroll
 * smoothly (instant under reduced motion) and lock the indicator to the
 * target so it doesn't hop through intermediate sections; the lock breaks
 * the moment the user scrolls by hand. ArrowUp/ArrowDown and Home/End move
 * focus through the links.
 */
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 visibleRef = 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 root = rootRef?.current ?? null;
    const visible = new Set<string>();
    const releaseLock = () => {
      if (clickLock.current) {
        clearTimeout(clickLock.current.timer);
        clickLock.current = null;
      }
    };

    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) {
          // While locked, only the click target may take over — the
          // indicator must not hop through intermediate sections.
          if (next !== clickLock.current.id) return;
          releaseLock();
        }
        setActiveId(next);
      },
      { root, rootMargin: "0% 0% -55% 0%" },
    );
    for (const heading of headings) observer.observe(heading);

    // The reading band can never reach a short final section; pin the last
    // entry once the root is scrolled to the very bottom.
    const scrollTarget: HTMLElement | Window = root ?? window;
    const onScroll = () => {
      const el = root ?? document.documentElement;
      if (el.scrollTop + el.clientHeight >= el.scrollHeight - 2) {
        const lastId = ids[ids.length - 1];
        if (clickLock.current && clickLock.current.id !== lastId) return;
        releaseLock();
        setActiveId(lastId);
      }
    };
    // Real scroll intent (wheel, touch) breaks a click lock immediately so
    // the spy follows the user, not the interrupted smooth-scroll.
    scrollTarget.addEventListener("scroll", onScroll, { passive: true });
    scrollTarget.addEventListener("wheel", releaseLock, { passive: true });
    scrollTarget.addEventListener("touchmove", releaseLock, { passive: true });

    return () => {
      observer.disconnect();
      scrollTarget.removeEventListener("scroll", onScroll);
      scrollTarget.removeEventListener("wheel", releaseLock);
      scrollTarget.removeEventListener("touchmove", releaseLock);
    };
  }, [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>('[aria-current="location"]');
    if (!active) {
      indicator.style.opacity = "0";
      visibleRef.current = false;
      return;
    }
    let top = 0;
    let node: HTMLElement | null = active;
    while (node && node !== nav) {
      top += node.offsetTop;
      node = node.offsetParent instanceof HTMLElement ? node.offsetParent : null;
    }
    const appearing = !visibleRef.current;
    if (!animate || appearing) indicator.style.transitionProperty = "none";
    indicator.style.height = `${active.offsetHeight}px`;
    indicator.style.transform = `translateY(${top}px)`;
    if (!animate) {
      indicator.style.opacity = "1";
      void indicator.offsetWidth;
      indicator.style.transitionProperty = "";
    } else if (appearing) {
      // Land in place and fade — never slide in from a stale position.
      void indicator.offsetWidth;
      indicator.style.transitionProperty = "";
      indicator.style.opacity = "1";
    } else {
      indicator.style.opacity = "1";
    }
    visibleRef.current = true;
  }, []);

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

  // Layout shifts snap; the initial observe fire is skipped so it cannot
  // cancel the first-appearance fade.
  React.useEffect(() => {
    const nav = navRef.current;
    if (!nav) return;
    let initial = true;
    const observer = new ResizeObserver(() => {
      if (initial) {
        initial = false;
        return;
      }
      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;
    const behavior: ScrollBehavior = reduced ? "auto" : "smooth";
    const root = rootRef?.current;
    if (root) {
      // Scroll only the root container — scrollIntoView would also drag
      // every outer ancestor (the page itself) along with it.
      const margin =
        parseFloat(window.getComputedStyle(target).scrollMarginTop) || 0;
      const top =
        root.scrollTop +
        target.getBoundingClientRect().top -
        root.getBoundingClientRect().top -
        margin;
      root.scrollTo({ top, behavior });
    } else {
      target.scrollIntoView({ behavior, block: "start" });
    }
  };

  const onNavKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
    const { key } = event;
    if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Home" && key !== "End") {
      return;
    }
    const nav = navRef.current;
    if (!nav) return;
    const links = Array.from(nav.querySelectorAll<HTMLAnchorElement>("a[href]"));
    if (links.length === 0) return;
    event.preventDefault();
    if (key === "Home") {
      links[0]?.focus();
      return;
    }
    if (key === "End") {
      links[links.length - 1]?.focus();
      return;
    }
    const index = links.indexOf(document.activeElement as HTMLAnchorElement);
    if (index === -1) {
      links[0]?.focus();
      return;
    }
    const step = key === "ArrowDown" ? 1 : -1;
    links[(index + step + links.length) % links.length]?.focus();
  };

  return (
    <nav
      ref={navRef}
      aria-label="Table of contents"
      data-slot="table-of-contents"
      onKeyDown={onNavKeyDown}
      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;
          const isActive = item.id === activeId;
          return (
            <li key={item.id}>
              <a
                href={`#${item.id}`}
                data-active={isActive || undefined}
                aria-current={isActive ? "location" : 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 aria-[current=location]:text-foreground"
              >
                <span className="truncate">{item.label}</span>
              </a>
            </li>
          );
        })}
      </ul>
    </nav>
  );
}