Table of Contents

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# Add this component (its dependencies come along)npx koalaui-cli@latest add table-of-contents

Usage#

usage.tsxTSX
import {  TableOfContents,  TableOfContentsTitle,  TableOfContentsList,  TableOfContentsItem,} from "@/components/ui/table-of-contents"<aside className="hidden lg:block">  <TableOfContents className="sticky top-24">    <TableOfContentsTitle>On this page</TableOfContentsTitle>    <TableOfContentsList>      {sections.map((section) => (        <TableOfContentsItem key={section.id} href={`#${section.id}`}>          {section.title}        </TableOfContentsItem>      ))}    </TableOfContentsList>  </TableOfContents></aside>

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.

Variants#

Six looks over one behaviour, so switching is a prop, never a rewrite. Line is the default: a hairline track with a brand 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 brand. 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 spy. Takes variant (line · progress · pill · dash · plain · minimap), density (compact · comfortable), numbered, value, defaultValue, onValueChange and container (a ref to a scroll pane; the window when omitted). Its aria-label defaults to "On this page"; give each nav its own when a page has two.

TableOfContentsTitle

The overline above the list, a <p>. asChild renders your own element instead, such as a heading.

TableOfContentsList

The <ol>. On line and progress it draws the track and the bar, on pill the chip, each its own ::before, so className="before:bg-foreground" restyles it. On minimap the list is the panel, and the dash column renders beside it from the same items.

TableOfContentsItem

One link in its <li>. Takes href (# plus the heading's id; a link that leaves the page is shown but not spied) and level (2 · 3). The active item carries aria-current="location".

useHeadings · useScrollSpy

useHeadings(selector?, key?) returns { id, text, level }[] for the headings matching selector (default "h2[id], h3[id]"), read again when key changes. useScrollSpy(ids, { container? }) returns the id of the section being read.

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.