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 ( <div {...api.getRootProps()}> <nav {...api.getNavProps()}> <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> </div> ) }

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.

<div {...api.getRootProps()}> <nav {...api.getNavProps()}> <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> </div>

Activating one item at a time

By default, every heading in view is active. Set multiple to false to keep only the topmost visible heading active. Near the end of the page, the last headings take turns as you scroll, so short sections at the bottom are still reachable.

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

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, or a wrapper around it, is scrollable, the machine scrolls it to keep the first active item visible. It never scrolls the content, so it doesn't interrupt scrolling to a heading. 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="nav"] { /* styles for the nav landmark */ } [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