Table of contents
An on this page list that follows the reader and marks the active heading.
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/table-of-contents
Install
One command adds the file to components/ui and the two icons it needs. There is nothing to configure: the component reads your theme tokens, so it looks right in light and dark the moment it lands.
Usage
Pass the headings in page order with their ids. The list follows the reader as they scroll and the active item gets a thin bar on the left. Clicking an item scrolls to that heading.
Levels
Level 3 items indent under the level 2 above them. Keep it to two levels: a deeper tree is hard to scan in a narrow rail and usually means the page wants splitting.
Controlled
Pass activeId to drive the active item yourself, for example from a router or a reading-progress store. The observer is switched off while activeId is set.
Accessibility
It is a nav with a list of real anchor links, so it works without JavaScript. The active link carries aria-current and the focus ring is the base one from the theme.
Scroll the article on the left and the rail on the right follows. Click to jump.
Controlled
activeId set by hand, with two level 3 items indented under Pricing.
Compact
No title, three items. Good for a short changelog or a settings page.
Usage
import { TableOfContents } from "@/components/ui/table-of-contents"
<TableOfContents
offset={64}
items={[
{ id: "install", label: "Install", level: 2 },
{ id: "usage", label: "Usage", level: 2 },
{ id: "props", label: "Props", level: 3 },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { id: string; label: string; level?: 2 | 3 }[] | The headings in page order. id must match the heading's id attribute. | |
| title | string | null | "On this page" | Small mono heading above the list. null hides it. |
| activeId | string | Controlled active id. Set it and the scroll spy switches off. | |
| offset | number | 0 | Pixels from the top of the scroll area before a heading counts as active. |
| root | RefObject<HTMLElement | null> | A scrolling element to spy on and scroll, instead of the window. |
Source
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export type TocItem = {
/** The id of the heading on the page. */
id: string;
label: string;
/** Level 3 items indent under the level 2 above them. */
level?: 2 | 3;
};
type TableOfContentsProps = Omit<React.ComponentProps<"nav">, "children" | "title"> & {
/** The headings, in page order. */
items: TocItem[];
/** Small heading above the list. Pass null to hide it. */
title?: string | null;
/** Controlled active id. Omit to let the scroll spy decide. */
activeId?: string;
/** Pixels from the top of the scroll area before a heading counts as active, for example a sticky header. */
offset?: number;
/** A scrolling element to spy on and scroll, instead of the window. */
root?: React.RefObject<HTMLElement | null>;
};
const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/**
* An "On this page" list that follows the reader with IntersectionObserver.
* The active item gets a 1px foreground bar on the left, nothing else is coloured.
*/
export function TableOfContents({
items,
title = "On this page",
activeId,
offset = 0,
root,
className,
...props
}: TableOfContentsProps) {
const spied = useScrollSpy(items, root, offset, activeId === undefined);
const active = activeId ?? spied;
const jump = (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {
const scroller = root?.current;
if (!scroller) return;
const heading = scroller.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
if (!heading) return;
event.preventDefault();
const top =
scroller.scrollTop + heading.getBoundingClientRect().top - scroller.getBoundingClientRect().top - offset;
scroller.scrollTo({ top, behavior: reducedMotion() ? "auto" : "smooth" });
};
return (
<nav
data-slot="table-of-contents"
aria-label={title || "On this page"}
className={cn("text-sm", className)}
{...props}
>
{title && <p className="m-0 mb-3 font-mono text-xs tracking-wide text-muted-foreground uppercase">{title}</p>}
<ul className="m-0 list-none border-l p-0">
{items.map((item) => {
const isActive = item.id === active;
return (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={isActive ? "location" : undefined}
onClick={(event) => jump(event, item.id)}
className={cn(
"-ml-px block border-l border-transparent py-1 pl-3 transition-colors duration-100 ease-out outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50",
item.level === 3 && "pl-6",
isActive ? "border-foreground text-foreground" : "text-muted-foreground",
)}
>
{item.label}
</a>
</li>
);
})}
</ul>
</nav>
);
}
/** The first heading inside the top 40% of the scroll area wins. When none is there, the last winner stays. */
function useScrollSpy(items: TocItem[], root: TableOfContentsProps["root"], offset: number, enabled: boolean) {
const [active, setActive] = React.useState<string | undefined>(undefined);
const key = items.map((item) => item.id).join("\n");
React.useEffect(() => {
if (!enabled || !key) return;
const ids = key.split("\n");
const scope: ParentNode = root?.current ?? document;
const headings = ids
.map((id) => scope.querySelector<HTMLElement>(`#${CSS.escape(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 first = ids.find((id) => visible.has(id));
if (first) setActive(first);
},
{ root: root?.current ?? null, rootMargin: `-${offset}px 0px -60% 0px`, threshold: 0 },
);
for (const heading of headings) observer.observe(heading);
return () => observer.disconnect();
}, [key, root, offset, enabled]);
return active;
}
For AI agents
Hand your agent this page, the markdown at /components/table-of-contents/prompt.md, or the Copy AI prompt button above.
When to use
- The right rail of a docs page, a long article or a changelog with four or more headings
- When readers need to see where they are and jump without scrolling
- Inside a scrolling panel, with root pointing at it
When not to use
- For a page with fewer than three headings: the rail is noise
- For navigating between pages, use a sidebar or section nav
- In a phone layout: hide it under lg or move it into a sheet
Agent notes
- Client component. The headings must already carry the ids you list; it does not generate them
- The spy watches the top 40% of the scroll area: the first listed heading inside it wins, the last winner stays between headings
- With root set, clicks scroll that element instead of the page; without it the links are plain anchors
- Set offset to the height of any sticky header, and add scroll-mt to the headings for the anchor fallback