Skip to content
Component installs need the registry setup
VegaStack Design

Table of Contents

The “On this page” outline of a document — a list or a rail that opens on hover or focus, a scroll spy, and a sheet for narrow screens.

Status
stable
Since
0.23.74
Accessibility pattern
nav of links, aria-current location, arrow keys

Last updated

Kitchen circuit

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Supply and isolation

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Wiring

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Cable sizes

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Earthing

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Panel labels

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Sign-off

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Install

Add Table of Contents from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.

pnpm dlx shadcn@latest add @vegastack/table-of-contents

The same command installs the registry items it composes: @vegastack/sheet, @vegastack/use-list-nav, @vegastack/use-media-query.

Usage

import { TableOfContents } from "@/components/ui/table-of-contents";

<TableOfContents
  variant="rail"
  items={outline} // TextEdit's onOutlineChange items, or { id, level, text }[]
  onNavigate={(id) => handle.current?.scrollToHeading(id)}
/>;

Feed it the headings of the page in document order. TextEdit hands them over through onOutlineChange, and both TextEdit and MarkdownView (headingIds) put the matching id on each heading, which is all the scroll spy needs: the marked item is the last heading whose top has passed a line min(180px, 28%) down the scroller, and the last heading once the scroller reaches its bottom — so a short closing section still lights up. Choosing an item calls onNavigate; without it, the heading is scrolled to the top (instantly under reduced motion — give headings a scroll-margin-top to clear a sticky header).

The outline is sticky by default: set --table-of-contents-top through its className (your header's height, [--table-of-contents-top:--spacing(14)]) and it sticks there and scrolls within the rest of the viewport. It renders nothing when no heading is in range, so an empty page shows no outline.

Wide and narrow are two instances. Show the rail (or list) in a column from a breakpoint, and the trigger instance — a header button that opens the outline in a left Sheet — below it:

<TableOfContents
  items={outline}
  onNavigate={go}
  trigger={
    <Button variant="ghost" size="sm" className="lg:hidden">
      Outline
    </Button>
  }
/>
<TableOfContents variant="rail" items={outline} onNavigate={go} className="hidden lg:block" />

Kitchen circuit

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Supply and isolation

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Wiring

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Cable sizes

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Earthing

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Panel labels

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Sign-off

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Scope

  • Owns: the outline's links, the scroll spy (useActiveHeading, exported), the rail's open and close, the sticky placement and the narrow-screen sheet.
  • Does not own: the headings or their ids (TextEdit, MarkdownView), or scrolling to a heading when you pass onNavigate (TextEdit's scrollToHeading).
  • Compose with: TextEdit onOutlineChange and handleRef, MarkdownView headingIds, and a header Button as the trigger.

Anatomy

TableOfContents

Examples

Rail

Short ticks whose width follows the level; the reached one is thicker. Rest the pointer on the rail, or Tab into it, and it opens into the labelled list over the content beside it;

Esc or leaving closes it. A rail row is 28px tall and 24px wide, so each tick is a full-size target.

Kitchen circuit

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Supply and isolation

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Wiring

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Cable sizes

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Earthing

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Panel labels

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Sign-off

Keep the cooker on its own radial circuit, test the RCD before the inspection and note the readings on the schedule.

Controlled active item

Pass activeId to decide the marked item yourself — onActiveIdChange still reports what the spy has reached. The marked item carries aria-current="location", a leading bar and a heavier weight, never colour alone.

Levels

Levels 1 to 3 show by default (minLevel, maxLevel); indentation is relative to the shallowest level shown.

Sheet for narrow screens

With trigger, the outline opens in a left Sheet titled with the label; choosing a heading closes the sheet, then calls onNavigate, so the page's scroll is unlocked by the time it moves.

Nothing chosen yet

Empty

No heading in range renders nothing — there is no empty message to show.

A page with no headings shows no outline.

API Reference

PropTypeDefaultDescription
items*readonly TableOfContentsItem[]—The headings, in document order — TextEdit's onOutlineChange items as they come.
activeIdstring—The active heading, controlled. Omit it and the built-in scroll spy decides; null marks none.
labelstring"On this page"The navigation's accessible name and its visible title (the list's heading, the rail's open panel, the sheet's title).
maxLevelnumber3The deepest heading level shown.
minLevelnumber1The shallowest heading level shown.
onActiveIdChange((id: string | null) => void)—Called with the heading the scroll spy has reached, whenever that changes — pair it with activeId to control the active item.
onNavigate((id: string) => void)—Called when an item is chosen — a click, or Enter/Space on the focused item. Scroll there yourself (handle.scrollToHeading(id)). When omitted, the heading document.getElementById(id) is scrolled to the top of its scroller — smoothly, or instantly under reduced motion; give it a scroll-margin-top to clear a sticky header. Modified clicks (new tab, copy link) are left to the browser.
scrollContainerReact.RefObject<HTMLElement | null>undefined (the nearest scroller)The element that scrolls the headings, for the scroll spy. Omit it to use the first heading's nearest scrolling ancestor, or the window when the page itself scrolls.
stickybooleantrueStick to the top of the scroller, at --table-of-contents-top (zero by default; set it through className, e.g. [--table-of-contents-top:--spacing(14)] for a 56px header), and scroll within 100dvh minus that offset.
triggerReact.ReactElement<unknown, string | React.JSXElementConstructor<any>>—Show the outline in a Sheet (side left) opened by this element instead of inline — for narrow screens. Choosing an item closes the sheet, then calls onNavigate. variant and sticky do not apply; ref and className reach the list's nav inside the sheet.
variantTableOfContentsVariant"list"list is an always-labelled, indented list. rail is a column of short ticks (their width follows the level, the active one is thicker) that opens into the labelled list on hover or keyboard focus, over the content beside it.

TableOfContentsItem

PropTypeDefaultDescription
id*string—The heading element's id — TextEdit (onOutlineChange) and MarkdownView (headingIds) set it.
level*number—The heading level, 1 for #/h1. Indentation is relative to the shallowest level shown.
text*string—The heading's text.

useActiveHeading

useActiveHeading(ids, options) returns the id of the heading the reader has reached, by the same rule the component uses, for hosts that draw their own outline.

PropTypeDefaultDescription
containerReact.RefObject<HTMLElement | null>undefined (the nearest scroller)The element that scrolls the headings. Omit it to use the first heading's nearest scrolling ancestor (an app shell's main, say), or the window when nothing between it and the page scrolls.
offsetnumberMath.min(180, 28% of the container's visible height)The activation line, in px below the top edge of the container's visible box. A heading at or above the line has been reached. Set it to your sticky header's height plus a little.

Accessibility

  • A <nav> landmark named by its visible title (label, "On this page"), holding an ordered list of real links (href="#id"), so copying a link or opening it in a new tab still works; a plain click or Enter is handed to onNavigate.
  • The reached heading's link carries aria-current="location"; the visible marker is a bar (list) or a thicker tick (rail) plus weight, never colour alone.
  • The list is one tab stop, and tabbing in lands on the reached heading. The rail opens on keyboard focus so its labels are visible, and its links keep their names while it is closed.
  • In a sheet, focus moves into the dialog, Esc closes it and focus returns to the trigger.
KeyAction
TabInto the outline, on the reached heading; out.
↑ / ↓Previous / next heading.
Home / EndFirst / last heading.
Enter / SpaceGo to the heading (onNavigate).
EscClose the open rail, or the sheet.
ContractStates tested
Behaviourdefault, active, spy-activation-line, spy-bottom-forces-last, rail-collapsed, rail-expanded, sheet-open, empty-renders-nothing, items-changed
Accessibilitysemantic-html, landmark-region, labeled, aria-current, keyboard-navigation, focus-visible, expanded, focus-return, browser-accessibility-test
Visualdefault, hover, focus, active, collapsed, expanded

Do / Don't

Do
Feed items straight from TextEdit's onOutlineChange and navigate with handle.scrollToHeading, so edits update the outline and the spy.
Don't
Collect headings from the DOM yourself, or scroll with a fixed pixel offset — set scroll-margin-top or --table-of-contents-top instead.
Do
Render two instances — a rail or list from a breakpoint, and a trigger instance in the header below it.
Don't
Hide the outline on narrow screens with no way to reach it, or show the rail and the sheet trigger at the same width.

On this page