TableOfContents

In-page scroll-spy contents list that highlights the section currently in view.

import { TableOfContents, TocItem }from "@/components/ui"

Specification

An in-page scroll-spy contents list that highlights the section currently in the viewport and smooth-scrolls to sections on click.

Props (6)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
items*TocItem[]—Sections to list; each id must match an element id in the page.
titlestring"On this page"Heading label shown above the list (also used as the nav aria-label).
classNamestring—Additional CSS classes merged onto the nav.
TocItem.id*string—Id of the target section element to observe and scroll to.
TocItem.label*string—Visible link text for the section.
TocItem.level2 | 3—Heading depth used to indent nested (level 3) items.

Scroll-spy

Full source
The complete component, read from src/components/ui/table-of-contents.tsx.
table-of-contents.tsx
"use client";

import { useEffect, useState, type MouseEvent as ReactMouseEvent } from "react";
import { cn } from "@/lib/utils";

export interface TocItem {

  id: string;

  label: string;

  level?: 2 | 3;
}

interface TableOfContentsProps {
  items: TocItem[];

  title?: string;
  className?: string;
}

export function TableOfContents({ items, title = "On this page", className }: TableOfContentsProps) {
  const [activeId, setActiveId] = useState<string | null>(items[0]?.id ?? null);

  useEffect(() => {
    if (typeof IntersectionObserver === "undefined") return;
    const observer = new IntersectionObserver(
      (entries) => {
        const visible = entries
          .filter((e) => e.isIntersecting)
          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
        if (visible[0]) setActiveId(visible[0].target.id);
      },
      { rootMargin: "0px 0px -70% 0px", threshold: 0 }
    );

    const els = items
      .map((it) => document.getElementById(it.id))
      .filter((el): el is HTMLElement => Boolean(el));
    els.forEach((el) => observer.observe(el));
    return () => observer.disconnect();
  }, [items]);

  const onClick = (e: ReactMouseEvent, id: string) => {
    const el = document.getElementById(id);
    if (!el) return;
    e.preventDefault();
    el.scrollIntoView({ behavior: "smooth", block: "start" });
    setActiveId(id);
    history.replaceState(null, "", `#${id}`);
  };

  if (items.length === 0) return null;

  return (
    <nav aria-label={title} className={cn("text-sm", className)}>
      <p className="mb-2 text-xs font-semibold uppercase tracking-wider text-text-tertiary">{title}</p>
      <ul className="space-y-0.5 border-l border-border">
        {items.map((it) => {
          const active = it.id === activeId;
          return (
            <li key={it.id}>
              <a
                href={`#${it.id}`}
                onClick={(e) => onClick(e, it.id)}
                className={cn(
                  "-ml-px block border-l py-1 transition-colors",
                  it.level === 3 ? "pl-6" : "pl-3",
                  active
                    ? "border-accent font-medium text-accent"
                    : "border-transparent text-text-secondary hover:border-border-hover hover:text-text"
                )}
              >
                {it.label}
              </a>
            </li>
          );
        })}
      </ul>
    </nav>
  );
}

Dependencies

To use TableOfContents you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  └─ table-of-contents.tsx
src/lib/
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}