TableOfContents
In-page scroll-spy contents list that highlights the section currently in view.
import { TableOfContents, TocItem }from "@/components/ui"
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 } 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: React.MouseEvent, 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