Table of Contents
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
npm install @zag-js/toc @zag-js/solid # or yarn add @zag-js/toc @zag-js/solid
npm install @zag-js/toc @zag-js/vue # or yarn add @zag-js/toc @zag-js/vue
npm install @zag-js/toc @zag-js/svelte # or yarn add @zag-js/toc @zag-js/svelte
Anatomy
Check the table of contents anatomy and part names.
Each part includes a
data-partattribute 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> ) }
import * as toc from "@zag-js/toc" import { normalizeProps, useMachine } from "@zag-js/solid" import { createMemo, createUniqueId, For } from "solid-js" 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: createUniqueId(), items }) const api = createMemo(() => toc.connect(service, normalizeProps)) return ( <nav {...api().getRootProps()}> <h5 {...api().getTitleProps()}>On this page</h5> <ul {...api().getListProps()}> <For each={api().items}> {(item) => ( <li {...api().getItemProps({ item })}> <a href={`#${item.value}`} {...api().getLinkProps({ item })}> {item.value} </a> </li> )} </For> </ul> </nav> ) }
<script setup> import * as toc from "@zag-js/toc" import { normalizeProps, useMachine } from "@zag-js/vue" import { computed } from "vue" const items = [ { value: "introduction", depth: 2 }, { value: "getting-started", depth: 2 }, { value: "installation", depth: 3 }, { value: "usage", depth: 2 }, ] const service = useMachine(toc.machine, { id: "1", items }) const api = computed(() => toc.connect(service, normalizeProps)) </script> <template> <nav v-bind="api.getRootProps()"> <h5 v-bind="api.getTitleProps()">On this page</h5> <ul v-bind="api.getListProps()"> <li v-for="item in api.items" :key="item.value" v-bind="api.getItemProps({ item })" > <a :href="`#${item.value}`" v-bind="api.getLinkProps({ item })"> {{ item.value }} </a> </li> </ul> </nav> </template>
<script lang="ts"> import * as toc from "@zag-js/toc" import { useMachine, normalizeProps } from "@zag-js/svelte" const items = [ { value: "introduction", depth: 2 }, { value: "getting-started", depth: 2 }, { value: "installation", depth: 3 }, { value: "usage", depth: 2 }, ] const id = $props.id() const service = useMachine(toc.machine, { id, items }) const api = $derived(toc.connect(service, normalizeProps)) </script> <nav {...api.getRootProps()}> <h5 {...api.getTitleProps()}>On this page</h5> <ul {...api.getListProps()}> {#each api.items as item} <li {...api.getItemProps({ item })}> <a href={`#${item.value}`} {...api.getLinkProps({ item })}> {item.value} </a> </li> {/each} </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 idsactiveItemsTocItem[]The active (visible) TOC itemsitemsTocItem[]The resolved items listsetActiveIds(value: string[]) => voidManually set the active heading idsscrollTo(value: string, details?: ScrollToDetails | undefined) => voidScrolls to the heading with the given id.getItemState(props: ItemProps) => ItemStateReturns the state of a TOC item