# Table Of Contents

> A React table of contents that lists a page's sections, highlights the one being read and jumps to any, with a pill and sheet on phones.

- Type: React component (Navigation)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add table-of-contents`
- Docs: https://wingo-ui.com/components/table-of-contents

## Interaction

Scroll the page and the section you are reading lights up: on the line variant a short bar glides along the rail to it on a smooth spring, on the dots variant its dot grows and fills. Click a row to glide to that section; the highlight goes straight to it instead of running through the sections in between, the heading lands just under the top bar, receives focus and the URL hash updates (without adding history). Tab or the arrow keys (Home / End for the first and last) move through the rows, Enter jumps. Opening a page with #section in the URL lands there without animation. Below collapseBelow the list turns into a sticky pill under the top bar with a reading ring and the current section, which crossfades as you scroll; tap it to open every section in a bottom sheet (48px rows, the current one checked), tap a row and the sheet closes, then the page jumps. Under reduced motion jumps are instant and nothing slides or crossfades.

## Installation

```bash
npx wingo-ui@latest add table-of-contents
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/table-of-contents` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/table-of-contents.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))

## Usage

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

export function Contract() {
  return (
    <div className="mx-auto max-w-5xl px-5 lg:grid lg:grid-cols-[minmax(0,1fr)_220px] lg:gap-12">
      <TableOfContents
        title="On this page"
        pillLabel="Contents"
        sheetTitle="Contract contents"
        className="pt-3 lg:col-start-2 lg:row-start-1 lg:pt-8"
      />
      <article className="lg:col-start-1 lg:row-start-1">
        <h1>Lease agreement</h1>
        <h2>1. The parties</h2>
        <p>Landlord: Olivia Bennett. Tenant: Andrew Collins.</p>
        <h2>4. Rent and payment</h2>
        <p>The monthly rent is $2,500.00.</p>
        <h3>4.1 Security deposit</h3>
        <p>One month of rent, returned within 15 days.</p>
      </article>
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items?` | `TocItem[]` |  | { id: string; label: ReactNode; level?: 2 \| 3; items?: TocItem[] }. Nested items or level: 3 rows become subsections. Leave empty to collect headings from the page. Ids missing in the DOM keep their row, jumping to them does nothing. |
| `headings?` | `string` | `"h2, h3"` | Selector used to collect items when items is empty, after mount and again (debounced 150ms) whenever the content changes. Headings without an id get one from their text; data-toc-ignore skips one, data-toc-label renames it. |
| `container?` | `HTMLElement \| RefObject<HTMLElement \| null> \| null` |  | Where headings are collected from. Default: the scroll container, else <main>, else the body. |
| `title?` | `ReactNode` | `"On this page"` | The eyebrow above the list, also the nav's aria-label when it is a string. "" hides it. |
| `pillLabel?` | `string` | `"Contents"` | The muted word on the phone pill before the current section. |
| `sheetTitle?` | `string` | `"On this page"` | The title of the bottom sheet the pill opens. Also names the nav landmark when title is empty or not a string. |
| `defaultSheetOpen?` | `boolean` | `false` | Opens the phone sheet on mount, uncontrolled. The sheet only opens below collapseBelow, where its pill shows. |
| `sheetOpen?` | `boolean` |  | The phone sheet's open state, controlled. Pair it with onSheetOpenChange. |
| `onSheetOpenChange?` | `(open: boolean) => void` |  | Called when the sheet opens or closes. |
| `variant?` | `"line" \| "dots" \| "plain"` | `"line"` | line: a rail with a segment that glides to the active row. dots: a dot per row that grows and fills. plain: text only, the active row in medium weight. |
| `size?` | `"sm" \| "md"` | `"sm"` | Rows at 13 / 14px. Every row is 44px tall on touch screens. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | Colors the indicator, the active row, the dots and the progress ring. |
| `color?` | `string` | `""` | Any CSS color. Replaces the tone. Leave empty to use the tone. |
| `showProgress?` | `boolean` | `false` | A 16px reading-progress ring and a percentage next to the title. Both follow the scroll every frame without re-rendering. The phone pill always has the ring. |
| `collapseBelow?` | `"md" \| "lg" \| "xl" \| "never" \| "always"` | `"lg"` | Below this width the list becomes a sticky pill that opens a sheet. Pure CSS, both are rendered, so the server HTML is right on a phone. always: the pill at every width (narrow columns, phone layouts). |
| `collapseOn?` | `"viewport" \| "container"` | `"viewport"` | What collapseBelow measures. viewport: media queries (768 / 1024 / 1280px). container: the nearest ancestor with the @container class, at the same widths, for pages that sit in a narrow pane, a card or a split view. Lay out the columns with the matching @3xl: / @5xl: / @7xl: variants. |
| `sticky?` | `boolean` | `true` | Pins the list 24px and the pill 8px under the top bar (--topbar-offset) while the page scrolls. Place it as a direct child of the layout that spans the whole page (a grid column, or before the article). |
| `offset?` | `number` | `16` | Pixels below the top bar where the reading line sits and where a jumped-to heading lands. --topbar-offset, and the pill's height when it shows, are added automatically. The demo's readout shows it. |
| `smooth?` | `boolean` | `true` | Smooth jumps. Always instant when the user prefers reduced motion. The demo's readout shows it. |
| `updateHash?` | `boolean` | `true` | A jump writes #id into the URL with history.replaceState, so it can be shared without adding history entries. Scrolling never does. The demo's readout shows it. |
| `scrollTarget?` | `HTMLElement \| Window \| RefObject<HTMLElement \| null> \| null` |  | What scrolls. Default: the nearest ScrollContainerProvider, else the headings' scrolling ancestor, else the window. |
| `onActiveChange?` | `(id: string \| null) => void` |  | Called with the id of the section being read whenever it changes. |
| `classNames?` | `Partial<Record<"header" \| "title" \| "progress" \| "scroll" \| "list" \| "item" \| "link" \| "rail" \| "indicator" \| "dot" \| "pill" \| "pillLabel" \| "pillRing" \| "sheet" \| "sheetItem", string>>` |  | Extra classes for the inner parts. scroll is the box that scrolls a list taller than the screen. |
| `className?` | `string` |  | Extra classes for the <nav> root. Other props go to it too. |

## Parts

### useActiveHeading (hook)

The scroll spy on its own, for a custom table of contents: the id of the heading being read. Signature: (ids: string[], { root?: ScrollTarget; offset?: number; disabled?: boolean }) => string | null.

### collectHeadings (function)

The headings of a container as items, in order, assigning slug ids (duplicates suffixed -2) to headings without one. Signature: (container: ParentNode | null, selector?: string) => TocItem[].

## Examples

- **Three variants**: line with a moving indicator, dots, and plain text, all following the same page.
- **Collected headings**: No items: the h2 and h3 of a product spec page are collected, given ids and kept up to date.
- **Reading progress**: showProgress adds a ring and a percentage next to the title of a long help article.
- **Settings left rail**: A plain md list on the left of a settings page, with level 2 and 3 sections.
- **Phone: pill and sheet**: collapseBelow="always": a sticky pill with the current section opens every section in a bottom sheet.
- **Custom color**: color="#0d9488" paints the indicator, the active row and the progress ring.

## See also

- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))
- [Scroll Progress](https://wingo-ui.com/components/scroll-progress) ([Markdown](https://wingo-ui.com/components/scroll-progress.md))
- [Hook Sidebar](https://wingo-ui.com/components/hook-sidebar) ([Markdown](https://wingo-ui.com/components/hook-sidebar.md))
- [Proximity Sidebar](https://wingo-ui.com/components/proximity-sidebar) ([Markdown](https://wingo-ui.com/components/proximity-sidebar.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))

---

Canonical: https://wingo-ui.com/components/table-of-contents
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
