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