An 'On this page' list with a scroll spy and a sliding current-section marker. It collects headings itself or takes a list, and scrolls below sticky headers.
Overview
Short introduction to overview. Enough text that the section has real height and scrolling feels natural.
What this covers
Details about what this covers, written as plain paragraphs for the demo.
Who it is for
Details about who it is for, written as plain paragraphs for the demo.
More detail follows here so the next heading is not in view yet.
Installation
Short introduction to installation. Enough text that the section has real height and scrolling feels natural.
Requirements
Details about requirements, written as plain paragraphs for the demo.
Add the package
Details about add the package, written as plain paragraphs for the demo.
More detail follows here so the next heading is not in view yet.
Usage
Short introduction to usage. Enough text that the section has real height and scrolling feels natural.
Basic example
Details about basic example, written as plain paragraphs for the demo.
Options
Details about options, written as plain paragraphs for the demo.
More detail follows here so the next heading is not in view yet.
Accessibility
Short introduction to accessibility. Enough text that the section has real height and scrolling feels natural.
More detail follows here so the next heading is not in view yet.
"use client";
import * as React from "react";
import { TableOfContents } from "@/components/ballmac/table-of-contents";
const sections = [
["Overview", ["What this covers", "Who it is for"]],
["Installation", ["Requirements", "Add the package"]],
["Usage", ["Basic example", "Options"]],
["Accessibility", []],
] as const;
export default function TableOfContentsDemo() {
const scroller = React.useRef<HTMLDivElement>(null);
return (
<div className="grid w-full max-w-2xl gap-6 rounded-xl border bg-card p-4 shadow-sm sm:grid-cols-[1fr_12rem]">
<div ref={scroller} tabIndex={0} aria-label="Guide" className="h-72 overflow-auto pe-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
<article className="grid gap-3 text-sm leading-relaxed text-muted-foreground">
{sections.map(([title, subs]) => (
<section key={title} className="grid gap-3">
<h2 className="pt-2 text-lg font-semibold tracking-tight text-foreground">{title}</h2>
<p>Short introduction to {title.toLowerCase()}. Enough text that the section has real height and scrolling feels natural.</p>
{subs.map((s) => (
<React.Fragment key={s}>
<h3 className="pt-1 text-base font-medium text-foreground">{s}</h3>
<p>Details about {s.toLowerCase()}, written as plain paragraphs for the demo.</p>
</React.Fragment>
))}
<p className="pb-10">More detail follows here so the next heading is not in view yet.</p>
</section>
))}
</article>
</div>
<TableOfContents headingsFrom={scroller} container={scroller} offset={12} className="max-sm:hidden" />
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/table-of-contentsInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/scroll @ballmac/motion-presets @ballmac/i18nCopy the source into your project.
components/ballmac/table-of-contents.tsx// Ballmac UI: Table Of Contents. https://ui.ballmac.com/components/table-of-contents "use client"; import * as React from "react"; import { motion, useReducedMotion } from "motion/react"; import { scrollToId, useScrollSpy, type ScrollContainer } from "@/lib/ballmac/scroll"; import { spring } from "@/lib/ballmac/motion"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; type TocItem = { /** The id of the heading in the page. */ id: string; /** Text of the link. */ title: string; /** Heading level (2 = top level, 3 = indented, and so on). */ level?: number; }; type TableOfContentsProps = Omit<React.ComponentProps<"nav">, "children"> & { /** Links to show. Leave out to collect headings from the page automatically. */ items?: TocItem[]; /** Where to look for headings when `items` is not given. Defaults to `<main>`, then the whole page. */ headingsFrom?: React.RefObject<HTMLElement | null>; /** Heading levels to collect automatically. */ levels?: number[]; /** Title above the list. Also the accessible name of the navigation. */ title?: string; /** Pixels reserved at the top for a sticky header; also where a section counts as "reached". */ offset?: number; /** A scrollable element to watch and scroll instead of the page. */ container?: ScrollContainer; /** Called when the reader chooses a link. */ onNavigate?: (id: string) => void; }; function slug(text: string) { return text .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } /** * "On this page" navigation with a scroll spy. The current section is marked with a sliding indicator and * `aria-current="location"`; choosing a link scrolls smoothly (instantly under reduced motion) below a sticky header. */ function TableOfContents({ items: itemsProp, headingsFrom, levels = [2, 3], title, offset = 96, container, onNavigate, className, ...props }: TableOfContentsProps) { const msg = useMessages() title ??= msg("table-of-contents.title", "On this page") const reduce = useReducedMotion(); const indicatorId = `toc-${React.useId()}`; const [found, setFound] = React.useState<TocItem[]>([]); const levelKey = levels.join(","); React.useEffect(() => { if (itemsProp) return; const frame = requestAnimationFrame(() => { const scope = headingsFrom?.current ?? document.querySelector("main") ?? document.body; const selector = levelKey.split(",").map((l) => `h${l}`).join(","); const next: TocItem[] = []; scope.querySelectorAll<HTMLElement>(selector).forEach((heading) => { const text = heading.textContent?.trim() ?? ""; if (!text) return; if (!heading.id) heading.id = slug(text); next.push({ id: heading.id, title: text, level: Number(heading.tagName.slice(1)) }); }); setFound(next); }); return () => cancelAnimationFrame(frame); }, [itemsProp, headingsFrom, levelKey]); const items = itemsProp ?? found; const ids = React.useMemo(() => items.map((i) => i.id), [items]); const active = useScrollSpy(ids, { offset, container }); const base = items.length ? Math.min(...items.map((i) => i.level ?? 2)) : 2; if (!items.length) return null; return ( <nav aria-label={title} data-slot="table-of-contents" className={cn("text-sm", className)} {...props}> <p className="mb-3 text-xs font-semibold tracking-wide text-foreground uppercase">{title}</p> <ul className="relative grid gap-0.5 border-s border-border"> {items.map((item) => { const isActive = item.id === active; return ( <li key={item.id}> <a href={`#${item.id}`} aria-current={isActive ? "location" : undefined} data-active={isActive || undefined} onClick={(event) => { event.preventDefault(); if (scrollToId(item.id, { offset: offset - 8, container })) { history.replaceState(null, "", `#${item.id}`); onNavigate?.(item.id); } }} style={{ paddingInlineStart: `${0.75 + ((item.level ?? 2) - base) * 0.75}rem` }} className="relative -ms-px block rounded-e-md py-1.5 pe-2 leading-snug text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[active]:font-medium data-[active]:text-foreground" > {isActive && ( <motion.span layoutId={indicatorId} aria-hidden="true" className="absolute inset-y-1 start-0 w-0.5 rounded-full bg-primary" transition={reduce ? { duration: 0 } : spring.snappy} /> )} {item.title} </a> </li> ); })} </ul> </nav> ); } export { TableOfContents, type TableOfContentsProps, type TocItem };Update the import paths to match your project setup.
Usage
import { TableOfContents } from "@/components/ballmac/table-of-contents"The full example is in the Code tab above.
Examples
Explicit items
"use client";
import { TableOfContents } from "@/components/ballmac/table-of-contents";
export default function TableOfContentsStates() {
return (
<div className="w-56 rounded-xl border bg-card p-4">
<TableOfContents
title="In this guide"
items={[
{ id: "intro", title: "Introduction", level: 2 },
{ id: "setup", title: "Setup", level: 2 },
{ id: "setup-env", title: "Environment", level: 3 },
{ id: "setup-keys", title: "API keys", level: 3 },
{ id: "deploy", title: "Deploy", level: 2 },
]}
/>
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
itemsLinks to show. Leave out to collect headings from the page automatically. | TocItem[] | — |
headingsFromWhere to look for headings when `items` is not given. Defaults to `<main>`, then the whole page. | React.RefObject<HTMLElement | null> | — |
levelsHeading levels to collect automatically. | number[] | [2, 3] |
titleTitle above the list. Also the accessible name of the navigation. | string | — |
offsetPixels reserved at the top for a sticky header; also where a section counts as "reached". | number | 96 |
containerA scrollable element to watch and scroll instead of the page. | ScrollContainer | — |
onNavigateCalled when the reader chooses a link. | (id: string) => void | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter | Scrolls to the section and updates the URL hash |
| Screen readers | A labelled navigation; the current section has aria-current=location |
| Reduced motion | Jumps instead of smooth scrolling; the marker does not glide |
Use with AI
Leave items out to collect h2 and h3 headings (they need text; missing ids are created). Current section gets aria-current=location. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Table Of Contents (@ballmac/table-of-contents) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Documentation and long articles
- Settings pages with many sections
Not for
- Horizontal in-page tabs; use section-tabs
- App navigation; use sidebar
Registry JSON: https://ui.ballmac.com/r/table-of-contents.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
Pairs well with
Scroll Progress
A thin reading-progress bar fixed to the top or bottom of the page, or to a scrollable panel, that fills on a soft spring (or exactly under reduced motion).
Section Tabs
Sticky in-page tabs that follow the reader's section, slide an indicator to it, keep the active tab in view on narrow screens and scroll to a section on click.
Animated Tabs
Radix Tabs with a pill or underline indicator that slides between triggers on a spring, and panels that fade in. Keeps full keyboard support and ARIA; controlled or uncontrolled.
Back To Top
A floating button that appears after scrolling, draws a progress ring, scrolls smoothly (instantly under reduced motion) and moves focus to the content.