Skip to main content

A table of contents tracks which headings are visible on the page using IntersectionObserver and highlights the corresponding TOC items. Commonly used in documentation sidebars.

Features

  • Tracks visible headings with IntersectionObserver
  • Supports multiple active headings simultaneously
  • Auto-scrolls the TOC sidebar to keep the active item visible
  • Animated indicator for the active item
  • Custom scroll container support
  • Controlled and uncontrolled active state

Installation

Install the table of contents package:

npm install @zag-js/toc @zag-js/react # or yarn add @zag-js/toc @zag-js/react

Anatomy

Check the table of contents anatomy and part names.

Each part includes a data-part attribute to help identify them in the DOM.

Usage

Import the table of contents package:

import * as toc from "@zag-js/toc"

The table of contents package exports two key functions:

  • machine - State machine logic.
  • connect - Maps machine state to JSX props and event handlers.

You'll need to provide items — an array of { value, depth } objects representing the headings on the page.

import * as toc from "@zag-js/toc" import { useMachine, normalizeProps } from "@zag-js/react" import { useId } from "react" const items = [ { value: "introduction", depth: 2 }, { value: "getting-started", depth: 2 }, { value: "installation", depth: 3 }, { value: "usage", depth: 2 }, ] export function TableOfContents() { const service = useMachine(toc.machine, { id: useId(), items }) const api = toc.connect(service, normalizeProps) return ( <nav {...api.getRootProps()}> <h5 {...api.getTitleProps()}>On this page</h5> <ul {...api.getListProps()}> {api.items.map((item) => ( <li key={item.value} {...api.getItemProps({ item })}> <a href={`#${item.value}`} {...api.getLinkProps({ item })}> {item.value} </a> </li> ))} </ul> </nav> ) }

Adding an indicator

Use getIndicatorProps() to render an animated bar that follows the active item. The indicator position is exposed as CSS variables on the root element.

<nav {...api.getRootProps()}> <h5 {...api.getTitleProps()}>On this page</h5> <ul {...api.getListProps()}> <div {...api.getIndicatorProps()} /> {api.items.map((item) => ( <li key={item.value} {...api.getItemProps({ item })}> <a href={`#${item.value}`} {...api.getLinkProps({ item })}> {item.value} </a> </li> ))} </ul> </nav>

Configuring the observer

Control how headings are detected with rootMargin and threshold.

const service = useMachine(toc.machine, { rootMargin: "-20px 0% -40% 0%", threshold: 0, })

Custom scroll container

By default, the machine observes the document viewport. Pass scrollEl to observe headings within a specific scrollable container.

const service = useMachine(toc.machine, { scrollEl: () => document.getElementById("content"), })

Auto-scrolling the TOC

When the TOC sidebar is scrollable, the machine auto-scrolls to keep the first active item visible. Disable this with autoScroll.

const service = useMachine(toc.machine, { autoScroll: false, })

Listening for active changes

Use onActiveChange to respond when visible headings change. The callback receives both activeIds and activeItems.

const service = useMachine(toc.machine, { onActiveChange(details) { // details => { activeIds: string[], activeItems: TocItem[] } console.log(details.activeIds) }, })

Controlled active state

Use activeIds and onActiveChange for controlled usage.

const service = useMachine(toc.machine, { activeIds, onActiveChange(details) { setActiveIds(details.activeIds) }, })

Getting item state

Use getItemState to check if an item is active, first, or last.

const itemState = api.getItemState({ item }) // itemState => { active: boolean, first: boolean, last: boolean, depth: number }

Styling guide

Each part includes a data-part attribute you can target in CSS.

[data-scope="toc"][data-part="root"] { /* styles for the root container */ } [data-scope="toc"][data-part="link"] { /* styles for each link */ } [data-scope="toc"][data-part="link"][data-active] { /* styles for the active link */ }

Active state

When a heading is visible, data-active is set on the corresponding item and link.

[data-scope="toc"][data-part="item"][data-active] { /* active item */ } [data-scope="toc"][data-part="link"][data-active] { /* active link */ }

Depth-based indentation

Use the --depth CSS variable to indent items based on heading level.

[data-scope="toc"][data-part="item"] { padding-inline-start: calc(var(--depth) * 0.75rem - 1rem); }

Indicator styling

The indicator position is exposed as CSS variables on the root element: --top, --left, --width, --height.

[data-scope="toc"][data-part="indicator"] { width: 2px; background-color: blue; position: absolute; inset-inline-start: 0; top: var(--top); height: var(--height); transition: top 0.15s, height 0.15s; }

Methods and Properties

Machine Context

The table of contents machine exposes the following context properties:

  • idsElementIds | undefinedThe ids of the elements in the TOC. Useful for composition.
  • itemsTocItem[]The TOC items with `value` (slug/id) and `depth` (heading level).
  • rootMarginstring | undefinedThe root margin for the IntersectionObserver. Controls the effective viewport area for determining active headings.
  • thresholdnumber | number[] | undefinedThe IntersectionObserver threshold. A value of `0` means the heading is active as soon as even one pixel is visible within the root margin area.
  • scrollEl(() => HTMLElement | null) | undefinedFunction that returns the scroll container element to observe within. Defaults to the document/viewport.
  • autoScrollboolean | undefinedWhether to auto-scroll the TOC container so the first active item is visible when active headings change.
  • scrollBehaviorScrollBehavior | undefinedThe default scroll behavior used when auto-scrolling the TOC container and when scrolling to a heading (via link click or `api.scrollTo`). Can be overridden per-call by passing `behavior` to `api.scrollTo`.
  • onActiveChange((details: ActiveChangeDetails) => void) | undefinedCallback when the active (visible) headings change.
  • activeIdsstring[] | undefinedThe controlled active heading ids.
  • defaultActiveIdsstring[] | undefinedThe default active heading ids when rendered. Use when you don't need to control the active headings.
  • dir"ltr" | "rtl" | undefinedThe document's text/writing direction.
  • idstringThe unique identifier of the machine.
  • getRootNode(() => ShadowRoot | Document | Node) | undefinedA root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.

Machine API

The table of contents api exposes the following methods:

  • activeIdsstring[]All currently active (visible) heading ids
  • activeItemsTocItem[]The active (visible) TOC items
  • itemsTocItem[]The resolved items list
  • setActiveIds(value: string[]) => voidManually set the active heading ids
  • scrollTo(value: string, details?: ScrollToDetails | undefined) => voidScrolls to the heading with the given id.
  • getItemState(props: ItemProps) => ItemStateReturns the state of a TOC item

Data Attributes

Item
data-scope
toc
data-part
item
data-value
The value of the item
data-depth
The depth of the item
data-active
Present when active or pressed
Link
data-scope
toc
data-part
link
data-value
The value of the item
data-active
Present when active or pressed

CSS Variables

Root
--top
The top position value
--left
The left position value
--width
The width of the element
--height
The height of the element
Item
--depth
The depth value for the Item
Edit this page on GitHub