Table of Contents

Pro

The on-this-page nav beside a long document. It marks the section being read as you scroll, with a bar that slides along a hairline track, and it needs nothing but the links themselves.

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Activate your Pro license (once per machine)npx koalaui-cli@latest login <key># Add this component (its dependencies come along)npx koalaui-cli@latest add table-of-contents

Usage#

This example is part of Koala UI Pro

The preview is yours to explore. Its code comes with a Pro license, paid once and used in every project.

Have a license? Unlock it here

There is no list of ids to keep in step: each item registers the heading its href points at, and the nav watches those headings. Pin the rail yourself (sticky under your header) and give the headings a scroll-margin-top that clears that header, which the spy reads too. The items are plain anchors, so the jump, the URL hash and the back button stay the browser's; for a glide, set scroll-behavior: smooth on html behind prefers-reduced-motion: no-preference.

Anatomy#

import {  TableOfContents,  TableOfContentsTitle,  TableOfContentsList,  TableOfContentsItem,} from "@/components/ui/table-of-contents"<TableOfContents>  <TableOfContentsTitle />  <TableOfContentsList>    <TableOfContentsItem href="#…" />  </TableOfContentsList></TableOfContents>

Variants#

Six looks over one behaviour, so switching is a prop, never a rewrite. Line is the default: a hairline track with an ink bar that slides to the section being read. Progress fills the same track from the top down to that section, so it also says how far in the reader is. Pill slides a soft chip behind the active row, the Sidebar's active fill, for a rail inside an app. Dash leads every row with a short rule that runs to full length on the active one. Plain marks it by ink and weight alone. Minimap rests as a column of dashes and opens into the list; it has its own section below. Click an item to move the mark.

Minimap#

The quietest rail: a column of dashes hung from the page's edge, one per section, subsections shorter, the one being read longer and in full ink. It shows the shape of the document without a word of text. Hover it, or Tab into it, and the menu surface grows out of the column into the full list, clipped from its corner so the dashes swell into the panel instead of a popover appearing beside them. A mouse click on a link lets it close as soon as the pointer leaves; keyboard focus holds it open. The panel opens toward the content, so give the rail a column at the edge of the page.

at rest · hover the dashes

Levels#

level={3} makes an item a subsection: it steps in 16px under its parent, and on the line variant the bar still runs down the same track, so the mark never jumps sideways between a section and its children. Two levels is the limit on purpose: a third reads as a sitemap, not a guide to the page.

Numbered#

numbered prefixes each item with its position, for a document people cite by clause: a policy, terms, a spec. The numbers are CSS counters on the list, so they always agree with the order on screen, and a level-3 item reads 2.1 under its parent. Screen readers already get the order from the <ol>, so the glyphs are hidden from them.

Density#

Compact rows are 32px, the Sidebar's, for a docs rail that has to fit a long page. Comfortable rows reach the 40px house minimum, so the whole row is an easy target on a marketing page. On a touch screen both grow to 44px: the row itself grows, since stacked rows can't borrow space from each other. Without the prop it follows the nearest DensityProvider, and compact when there is none.

Headings from the page#

useHeadings(selector) reads the headings out of the DOM, so a docs or blog layout builds its items from whatever the page renders. Levels are relative: the shallowest heading found is a section and anything deeper a subsection, so an article built from h3s nests the same as one built from h2s. Controls inside a heading (a copy-link anchor) are left out of its label. In a layout that persists across routes, pass the pathname as the second argument to read them again.

Requirements

React 19 and Tailwind CSS v4. The components are source you own, so there is no runtime package to keep in step with.

Install

Run the CLI once for the tokens and helpers, then add components as you need them.

With the CLI

npx koalaui-cli init writes the tokens into your stylesheet and the helpers into lib/. npx koalaui-cli add table-of-contents copies the component and everything it depends on.

By hand

Copy the folder from components/ui into your project and install tailwind-variants and tailwind-merge. The component imports its helpers from lib/.

Configure

Everything a component paints comes from a token, so theming is a stylesheet change.

Tokens

Colors, radius, shadows and motion are CSS variables in globals.css. Change --radius once and every corner follows.

Density

Wrap an app shell in DensityProvider and every component inside tightens its spacing. A prop on one instance still wins.

Next steps

Browse the components, or start from a section and make it yours.

How the spy decides#

  • A heading counts as reached once it sits where an anchor jump would park it: the top of the view plus its own scroll-margin-top. The spy reads the margin off the heading, so it agrees with the jump under a sticky header without a second offset.
  • The first heading not yet reached is the section being read if it is already in the top half of the view; lower down, the reader is still in the one before it.
  • At the very end of a page that scrolled, the last section wins, since the closing sections are often too short to ever reach the top.
  • A clicked item holds the mark while the page travels to it, so a smooth scroll doesn't flick through every section on the way, and hands it back as soon as the reader scrolls themselves.
  • container watches a scroll pane instead of the window (every demo on this page does). There, an item scrolls the pane itself, since a native jump would move the page around it too.
  • A rail that scrolls on its own, capped at the viewport height like the one beside this page, keeps the active item in view as the mark moves. Only the rail scrolls, never the page.

Reacting to the section#

onValueChange hears the id of the section being read, from the spy or a click: update a reading bar, the document title, an analytics event. Pass value to drive the mark yourself, and defaultValue so the server render already marks the first section instead of waiting for the spy's first reading.

Reading The brief

A nav of your own#

useScrollSpy(ids) is the same spy without the list, for when a rail doesn't fit: a phone that shows the section being read in a bar instead. It starts on the first id, so the server render already has an answer. Here the headings carry scroll-mt-12 to clear the bar, and the spy honors it.

The brief1 / 5

API reference#

TableOfContents

The <nav> and the scroll-spy. Forwards all native <nav> props; every part accepts className, merged last.

PropTypeDefaultDescription
variant"line" | "progress" | "pill" | "dash" | "plain" | "minimap""line"Six looks over one behaviour: a sliding bar, a filling track, a sliding chip, leading rules, ink alone, or a dash column that opens into a panel.
density"compact" | "comfortable""compact"32px or 40px rows (44px on touch). Falls back to the nearest DensityProvider.
numberedbooleanfalseNumber the items (1., and 2.1 for a level-3 item).
valuestring-The active section's id, to drive the mark yourself. The spy still reports through onValueChange.
defaultValuestring-The section marked before the spy's first reading, so the server render already has one.
onValueChange(id: string) => void-Called with the id of the section being read, as the reader scrolls or clicks an item.
containerReact.RefObject<HTMLElement | null>-A scroll pane to watch instead of the window; items then scroll the pane alone.
aria-labelstring"On this page"The nav's accessible name; give each nav its own when a page has two.

TableOfContentsTitle

The overline above the list (visually hidden on minimap). Forwards all native <p> props.

PropTypeDefaultDescription
asChildbooleanfalseRender your own element instead, such as an <h2>.

TableOfContentsList

The <ol>. On line, progress and pill it draws the track, bar or chip as its own ::before, so className="before:bg-foreground" restyles it; on minimap it is the panel, with the dash column beside it. Forwards all native <ol> props.

Data attributeDescription
data-indicator"visible" once the sliding mark has an active item to sit on.
data-indicator-motionPresent once the mark may animate, so it never slides in on load.
CSS variableDescription
--toc-indicator-yThe mark's offset from the top of the list (line, progress, pill).
--toc-indicator-hThe mark's height; on progress it runs to the bottom of the active row.
--toc-rest-wThe dash column's width, the corner the minimap panel grows from.
--toc-rest-hThe dash column's height, the corner the minimap panel grows from.

TableOfContentsItem

One link in its <li>. The active item carries aria-current="location". Forwards all native <a> props.

PropTypeDefaultDescription
href*string-# plus the heading's id, which the spy watches. A link that leaves the page is shown but not spied.
level2 | 32A section, or a subsection that steps in under its parent.
Data attributeDescription
data-level"2" or "3".

useHeadings

useHeadings(selector?, key?) reads the matching headings from the DOM as { id, text, level }[]. Levels are relative to the shallowest heading found; returns [] on the server and the first render.

PropTypeDefaultDescription
selectorstring"h2[id], h3[id]"The headings to read.
keyunknown-Read again when it changes, e.g. the pathname in a persistent layout.

useScrollSpy

useScrollSpy(ids, options?) returns the id of the section being read, for a nav of your own. Starts on the first id.

PropTypeDefaultDescription
ids*readonly string[]-The heading ids to watch, in document order.
containerReact.RefObject<HTMLElement | null>-Option: a scroll pane to watch instead of the window.

tableOfContentsVariants

The tv recipe behind every part, for styling a nav of your own to match.

FAQ#

Because the items already say where they point. Each TableOfContentsItem registers the heading its href names, so the spy always watches exactly the links on screen, and adding or removing an item needs no second edit elsewhere.