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.

Customize
Behavior

Installation

npx shadcn@latest add https://craftbits.dev/r/toc.json

Usage

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

  1. Flat items array. Each entry is { id, label, depth? }. id matches the heading's DOM id; label is 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.
  2. IntersectionObserver tracking. When uncontrolled, the component subscribes to every heading element by id and watches them with one IntersectionObserver. The default rootMargin (-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.
  3. Controlled + uncontrolled. Pass activeId to drive tracking from outside (URL hash, another scroll-spy hook) — the internal observer disables itself. Pass defaultActiveId for an uncontrolled starting state. Either way, onActiveChange reports every change.
  4. Click-to-scroll. Clicking an entry calls element.scrollIntoView({ behavior }). The default is smooth; pass scrollBehavior="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.
  5. Depth-driven indent + accent rail. Each entry indents 0.75rem per depth level. The active entry shows a 12px accent-colored rail to its left and switches its label to serif italic. Inactive entries sit in cb-fg-subtle with the rail transparent.
  6. Cleanup. The IntersectionObserver is disconnected in the useEffect cleanup — required because the observed elements may be unmounted by the parent.

Props

PropTypeDefaultDescription
itemsreadonly ToCItem[]Ordered heading entries. Each ToCItem is { id, label, depth? }.
activeIdstringControlled active id. When set, the internal observer is disabled.
defaultActiveIdstringUncontrolled initial active id. Ignored if activeId is provided.
onActiveChange(id: string) => voidCalled whenever the active id changes — from the observer or a click.
rootMarginstring'-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-labelstring'Table of contents'Override the <nav>'s accessible name.
classNamestringMerged onto the rendered <nav>.
...restHTMLAttributes<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-collected h1, h2, h3 from the DOM and slugified missing ids — convenient for blog posts, brittle anywhere else. craft-bits flips the contract: the consumer provides the items (serializable, testable, SSR-safe), the component owns the observer.