ToC
An auto-tracking table of contents — pass a flat list of headings and the component renders an <ol> of anchored links, then uses an IntersectionObserver to highlight whichever section is closest to the top of the viewport.
Introduction
Scroll the column on the right — the entry on the left highlights the section closest to the top of the viewport. Click any entry to jump.
Installation
Scroll the column on the right — the entry on the left highlights the section closest to the top of the viewport. Click any entry to jump.
Usage
Scroll the column on the right — the entry on the left highlights the section closest to the top of the viewport. Click any entry to jump.
Active tracking
Scroll the column on the right — the entry on the left highlights the section closest to the top of the viewport. Click any entry to jump.
Tuning the band
Scroll the column on the right — the entry on the left highlights the section closest to the top of the viewport. Click any entry to jump.
Accessibility
Scroll the column on the right — the entry on the left highlights the section closest to the top of the viewport. Click any entry to jump.
Installation
npx shadcn@latest add https://craftbits.dev/r/toc.jsonUsage
Render ToC alongside an article whose <h*> elements carry stable ids.
import { ToC } from "@craft-bits/core";
const items = [
{ id: "intro", label: "Introduction", depth: 1 },
{ id: "install", label: "Installation", depth: 2 },
{ id: "usage", label: "Usage", depth: 2 },
{ id: "accessibility", label: "Accessibility", depth: 1 },
];
<aside className="sticky top-24">
<ToC items={items} />
</aside>
<article>
<h1 id="intro">Introduction</h1>
<h2 id="install">Installation</h2>
<h2 id="usage">Usage</h2>
<h1 id="accessibility">Accessibility</h1>
</article>The component picks up the headings by their id — no DOM crawling, no global selector. The consumer is in charge of which headings are listed and what depth each one is.
Understanding the component
- Flat
itemsarray. Each entry is{ id, label, depth? }.idmatches the heading's DOM id;labelis what renders in the sidebar;depth(1-based) drives indentation. Composing as data rather than children keeps the data model serializable — perfect for CMS-driven or generated tables of contents. IntersectionObservertracking. When uncontrolled, the component subscribes to every heading element by id and watches them with oneIntersectionObserver. The defaultrootMargin(-20% 0px -70% 0px) carves out a thin band 20% from the top of the viewport — a heading is "active" once it crosses into that band. The topmost intersecting heading wins.- Controlled + uncontrolled. Pass
activeIdto drive tracking from outside (URL hash, another scroll-spy hook) — the internal observer disables itself. PassdefaultActiveIdfor an uncontrolled starting state. Either way,onActiveChangereports every change. - Click-to-scroll. Clicking an entry calls
element.scrollIntoView({ behavior }). The default issmooth; passscrollBehavior="instant"to jump. The component also flips the new id active immediately so the highlight tracks the click rather than waiting for the observer to catch up. - Depth-driven indent + accent rail. Each entry indents
0.75remper depth level. The active entry shows a 12px accent-colored rail to its left and switches its label to serif italic. Inactive entries sit incb-fg-subtlewith the rail transparent. - Cleanup. The
IntersectionObserveris disconnected in theuseEffectcleanup — required because the observed elements may be unmounted by the parent.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly ToCItem[] | — | Ordered heading entries. Each ToCItem is { id, label, depth? }. |
activeId | string | — | Controlled active id. When set, the internal observer is disabled. |
defaultActiveId | string | — | Uncontrolled initial active id. Ignored if activeId is provided. |
onActiveChange | (id: string) => void | — | Called whenever the active id changes — from the observer or a click. |
rootMargin | string | '-20% 0px -70% 0px' | The IntersectionObserver rootMargin. Defines where the "active band" lives. |
scrollBehavior | 'smooth' | 'instant' | 'smooth' | Scroll behavior used when an entry is clicked. |
aria-label | string | 'Table of contents' | Override the <nav>'s accessible name. |
className | string | — | Merged onto the rendered <nav>. |
...rest | HTMLAttributes<HTMLElement> | — | Any other <nav> prop. |
Accessibility
- The root is
<nav aria-label="Table of contents">— a landmark assistive tech can jump to. - The list is
<ol>because order is meaningful — screen readers announce position ("item 3 of 6"). - The active entry carries
aria-current="location"(the W3C-recommended token for "the section the user is currently viewing") so the active state is communicated without depending on color. - Every link has a visible
focus-visible:ring keyed to--cb-accent, offset from--cb-bg, so keyboard users always see the current target. - The accent rail is
aria-hidden— it's purely decorative. - Color contrast in the default theme: active uses
--cb-fg; inactive uses--cb-fg-subtle, hover lifts to--cb-fg-muted— both pass WCAG AA against--cb-bg.
Credits
- Extracted from:
terminal-dreams(src/components/retro/ToC.tsx). The original auto-collectedh1, h2, h3from the DOM and slugified missing ids — convenient for blog posts, brittle anywhere else. craft-bits flips the contract: the consumer provides theitems(serializable, testable, SSR-safe), the component owns the observer.