# Table of Content (TOC) (/ui/components/react/toc)



<Playground component="toc" />

## Usage [#usage]

```tsx
import { Toc, TocList, TocItem, TocLink } from '@appica/ui-react/toc'
```

```tsx
<Toc aria-label="On this page">
  <TocList>
    <TocItem>
      <TocLink href="#installation">Installation</TocLink>
    </TocItem>
    <TocItem>
      <TocLink href="#usage">Usage</TocLink>
    </TocItem>
    <TocItem>
      <TocLink href="#configuration" depth={3}>
        Configuration
      </TocLink>
    </TocItem>
    <TocItem>
      <TocLink href="#faq">FAQ</TocLink>
    </TocItem>
  </TocList>
</Toc>
```

`Toc` is an "on this page" sidebar that watches the document and highlights the link whose heading is currently in view. Each `TocLink`'s `href` points at a heading's `id` (`href="#installation"` → `<h2 id="installation">`); the component registers those targets, observes them with an `IntersectionObserver`, and marks the visible ones `data-active`. When several headings share the screen the indicator stretches to cover the whole span, and `aria-current` lands on the topmost one.

It's purely compositional — no list data is passed in. Lay out the items yourself (typically generated from your page's headings) and set each link's `depth` to match its heading level so deeper headings indent. Everything is driven by the real DOM, so the headings just need matching `id`s.

Because scroll-spy needs a scroll position to track, the live examples below put the article in a self-contained scrolling panel — scroll inside it to watch the active link follow along. They also intercept link clicks to scroll that panel rather than the page; on a real page where the TOC tracks the document, you'd skip that and let the anchor jump the page natively.

## Examples [#examples]

### Default [#default]

A flat list of top-level headings. As you scroll the article, the link tracking the visible heading lights up and the indicator slides to it.

```tsx
'use client'

import { type MouseEvent } from 'react'
import { Toc, TocList, TocItem, TocLink } from '@appica/ui-react/toc'

const SECTIONS = [
  {
    id: 'toc-demo-getting-started',
    title: 'Getting started',
    body: 'Install the package with your favourite package manager and import the component you need. Every component ships from its own subpath, so your bundler only pulls in what you actually use and tree-shaking does the rest. There is no global stylesheet to wire up beyond the theme layer — the components bring their own styles, so a fresh install renders correctly with nothing else configured. A component behaves identically on the server and the client, so you can drop it anywhere in a React tree without reaching for a "use client" boundary unless your own code needs one. Sensible defaults mean most components look right immediately, and every visual choice can be overridden through props or class names. Once you are comfortable with one component the rest follow the same composition-first API, so there is very little new to learn as you reach for more of them across a project.',
  },
  {
    id: 'toc-demo-theming',
    title: 'Theming',
    body: 'Colors, radii, and motion are driven entirely by CSS variables. Override them at any level of the tree to retheme a single section or the whole application, with no build step or configuration. Light and dark modes are just two sets of the same variables, so a theme toggle is a single class on a parent element. Because everything resolves at runtime, you can even let users tweak the palette live.',
  },
  {
    id: 'toc-demo-accessibility',
    title: 'Accessibility',
    body: 'Components follow the WAI-ARIA patterns out of the box — roving focus, correct roles, and labelled landmarks — so keyboard and screen-reader support comes for free. Focus is trapped and restored where it should be, and every interactive element is reachable by keyboard. We test the primitives against axe to catch regressions early. You are still responsible for naming your own instances, but the wiring underneath is handled.',
  },
  {
    id: 'toc-demo-rtl',
    title: 'Right-to-left',
    body: 'Logical properties and a direction provider mean every component mirrors cleanly under dir="rtl" without any extra work on your part. Padding, borders, and animations all flip to follow the reading direction. Arrow-key navigation and popup placement respect the resolved direction too. Switching a whole app to RTL is a single attribute on the html element plus the provider.',
  },
]

// Demo only: scrolls within this panel instead of the page so the example stays put.
// A page-level TOC needs none of this — let the anchor jump the page natively.
function scrollWithinPanel(event: MouseEvent<HTMLElement>) {
  const link = (event.target as HTMLElement).closest('a[href^="#"]')
  if (!link) return
  const id = decodeURIComponent(link.getAttribute('href')!.slice(1))
  const heading = document.getElementById(id)
  const panel = heading?.closest('[data-scroll-panel]')
  if (!heading || !panel) return
  event.preventDefault()
  const gap = parseFloat(getComputedStyle(heading).scrollMarginTop) || 0
  const top = heading.getBoundingClientRect().top - panel.getBoundingClientRect().top + panel.scrollTop - gap
  panel.scrollTo({ top, behavior: 'smooth' })
}

export default function TocDefault() {
  return (
    <div className="flex w-full max-w-xl gap-4">
      <div
        data-scroll-panel
        className="border-border h-64 flex-1 scrollbar-none overflow-auto rounded-lg border p-4 [&::-webkit-scrollbar]:hidden"
      >
        {SECTIONS.map((section) => (
          <section key={section.id} className="mb-6 last:mb-0">
            <h3 id={section.id} className="text-foreground-intense mb-2 scroll-mt-6 font-semibold">
              {section.title}
            </h3>
            <p className="text-sm leading-relaxed">{section.body}</p>
          </section>
        ))}
      </div>

      <Toc className="w-38 shrink-0" onClick={scrollWithinPanel}>
        <TocList>
          {SECTIONS.map((section) => (
            <TocItem key={section.id}>
              <TocLink href={`#${section.id}`}>{section.title}</TocLink>
            </TocItem>
          ))}
        </TocList>
      </Toc>
    </div>
  )
}
```

### Nested headings [#nested-headings]

Give each `TocLink` a `depth` (2–6) matching its heading level and deeper entries indent automatically — the typical `<h2>`/`<h3>` outline. The active indicator runs down the shared left rail regardless of depth.

```tsx
'use client'

import { type MouseEvent } from 'react'
import { Toc, TocList, TocItem, TocLink } from '@appica/ui-react/toc'

const HEADINGS = [
  {
    id: 'toc-nested-install',
    title: 'Installation',
    depth: 2,
    body: 'Add the package to your project before importing anything. It has a single peer dependency on React and works with any bundler that understands ES modules, so there is no special configuration to add and nothing to register globally. Pick the package manager your team already uses — the install command is the only thing that differs between them, and the resulting dependency is identical. In a monorepo, add it to the package that actually renders UI rather than the workspace root, so each app pins the version it depends on and upgrades stay scoped. Commit your lockfile afterwards so every environment, including CI, resolves exactly the same build of the library. Once it is installed you can import any component from its own subpath and start composing immediately, with no build step and no global stylesheet to wire up first.',
  },
  {
    id: 'toc-nested-pnpm',
    title: 'pnpm',
    depth: 3,
    body: 'Run pnpm add to install it into the current workspace. In a monorepo, scope it to the package that renders UI rather than the root so versions stay explicit per app.',
  },
  {
    id: 'toc-nested-npm',
    title: 'npm',
    depth: 3,
    body: 'npm install works identically and writes the dependency to your package.json. Commit the lockfile so every environment resolves the same version of the library.',
  },
  {
    id: 'toc-nested-usage',
    title: 'Usage',
    depth: 2,
    body: 'Import the parts you need and compose them. Components are designed to be assembled rather than configured through long prop lists, so most screens are just JSX. The pieces share a styling context, so a variant set once flows down.',
  },
  {
    id: 'toc-nested-import',
    title: 'Importing',
    depth: 3,
    body: 'Each component lives at its own subpath, so import from there rather than a barrel. This keeps your bundle lean and makes it obvious which parts a file actually depends on.',
  },
  {
    id: 'toc-nested-compose',
    title: 'Composing',
    depth: 3,
    body: 'Nest the sub-components to build the shape you want, and reach for the render prop when you need to project a part onto your own element. Composition is the primary extension point across the whole library.',
  },
  {
    id: 'toc-nested-faq',
    title: 'FAQ',
    depth: 2,
    body: 'Answers to the questions that come up most often, from server-component support to bundle size and theming. If something is missing here, the per-component pages go deeper.',
  },
]

// Demo only: scrolls within this panel instead of the page so the example stays put.
// A page-level TOC needs none of this — let the anchor jump the page natively.
function scrollWithinPanel(event: MouseEvent<HTMLElement>) {
  const link = (event.target as HTMLElement).closest('a[href^="#"]')
  if (!link) return
  const id = decodeURIComponent(link.getAttribute('href')!.slice(1))
  const heading = document.getElementById(id)
  const panel = heading?.closest('[data-scroll-panel]')
  if (!heading || !panel) return
  event.preventDefault()
  const gap = parseFloat(getComputedStyle(heading).scrollMarginTop) || 0
  const top = heading.getBoundingClientRect().top - panel.getBoundingClientRect().top + panel.scrollTop - gap
  panel.scrollTo({ top, behavior: 'smooth' })
}

export default function TocNested() {
  return (
    <div className="flex w-full max-w-xl gap-4">
      <div
        data-scroll-panel
        className="border-border h-64 flex-1 scrollbar-none overflow-auto rounded-lg border p-4 [&::-webkit-scrollbar]:hidden"
      >
        {HEADINGS.map((heading) => (
          <section key={heading.id} className="mb-6 last:mb-0">
            {heading.depth === 2 ? (
              <h3 id={heading.id} className="text-foreground-intense mb-2 scroll-mt-6 font-semibold">
                {heading.title}
              </h3>
            ) : (
              <h4 id={heading.id} className="text-foreground-intense mb-2 scroll-mt-6 text-sm font-medium">
                {heading.title}
              </h4>
            )}
            <p className="text-sm leading-relaxed">{heading.body}</p>
          </section>
        ))}
      </div>

      <Toc className="w-38 shrink-0" onClick={scrollWithinPanel}>
        <TocList>
          {HEADINGS.map((heading) => (
            <TocItem key={heading.id}>
              <TocLink href={`#${heading.id}`} depth={heading.depth}>
                {heading.title}
              </TocLink>
            </TocItem>
          ))}
        </TocList>
      </Toc>
    </div>
  )
}
```

### With icons [#with-icons]

`TocLink` takes arbitrary children, so you can prefix each entry with an [icon](/ui/icons). Set the link to `flex` and size the SVG; the icon inherits the link's color, so it brightens along with the label when active.

```tsx
'use client'

import { type MouseEvent } from 'react'
import { Toc, TocList, TocItem, TocLink } from '@appica/ui-react/toc'
import { Rocket, Palette, Accessible, TextDirectionRtl } from '@appica/icons-react'

const SECTIONS = [
  {
    id: 'toc-icons-getting-started',
    title: 'Getting started',
    icon: Rocket,
    body: 'Install the package and import the component you need from its own subpath. There is nothing global to configure — components bring their own styles, so you can drop one into a page and it renders correctly straight away, with no provider or stylesheet to register first. Tree-shaking keeps your bundle to only the parts you import, and there is no runtime style injection to worry about. A component works the same whether it is rendered on the server or the client, so you can use it anywhere in a React tree without a "use client" boundary unless your own code needs one. Sensible defaults mean it looks right immediately, and you can override any of it through props or class names. Once you are comfortable with one component the rest follow the same composition-first API, so there is very little new to learn as you reach for more of them.',
  },
  {
    id: 'toc-icons-theming',
    title: 'Theming',
    icon: Palette,
    body: 'Colors, radii, and motion are driven by CSS variables you can override anywhere in the tree. Light and dark are two sets of the same variables, so a theme switch is a single class on a parent. There is no build step involved, and values resolve live at runtime.',
  },
  {
    id: 'toc-icons-accessibility',
    title: 'Accessibility',
    icon: Accessible,
    body: 'Components follow the WAI-ARIA patterns out of the box, so keyboard support and screen-reader semantics come for free. Focus management, roles, and labelled landmarks are handled by the primitives. You still name your own instances, but the wiring underneath is done for you.',
  },
  {
    id: 'toc-icons-rtl',
    title: 'Right-to-left',
    icon: TextDirectionRtl,
    body: 'Logical properties mean every component mirrors cleanly under dir="rtl". Padding, borders, focus order, and popup placement all flip to follow the reading direction. Switching the whole app is a single attribute plus the direction provider.',
  },
]

// Demo only: scrolls within this panel instead of the page so the example stays put.
// A page-level TOC needs none of this — let the anchor jump the page natively.
function scrollWithinPanel(event: MouseEvent<HTMLElement>) {
  const link = (event.target as HTMLElement).closest('a[href^="#"]')
  if (!link) return
  const id = decodeURIComponent(link.getAttribute('href')!.slice(1))
  const heading = document.getElementById(id)
  const panel = heading?.closest('[data-scroll-panel]')
  if (!heading || !panel) return
  event.preventDefault()
  const gap = parseFloat(getComputedStyle(heading).scrollMarginTop) || 0
  const top = heading.getBoundingClientRect().top - panel.getBoundingClientRect().top + panel.scrollTop - gap
  panel.scrollTo({ top, behavior: 'smooth' })
}

export default function TocWithIcons() {
  return (
    <div className="flex w-full max-w-xl gap-4">
      <div
        data-scroll-panel
        className="border-border h-64 flex-1 scrollbar-none overflow-auto rounded-lg border p-4 [&::-webkit-scrollbar]:hidden"
      >
        {SECTIONS.map((section) => (
          <section key={section.id} className="mb-6 last:mb-0">
            <h3 id={section.id} className="text-foreground-intense mb-2 scroll-mt-6 font-semibold">
              {section.title}
            </h3>
            <p className="text-sm leading-relaxed">{section.body}</p>
          </section>
        ))}
      </div>

      <Toc className="w-38 shrink-0" onClick={scrollWithinPanel}>
        <TocList>
          {SECTIONS.map((section) => (
            <TocItem key={section.id}>
              <TocLink href={`#${section.id}`} className="flex items-center gap-2 [&_svg]:size-4 [&_svg]:shrink-0">
                <section.icon />
                {section.title}
              </TocLink>
            </TocItem>
          ))}
        </TocList>
      </Toc>
    </div>
  )
}
```

## Scroll offset [#scroll-offset]

On a real page with a sticky header, headings become "current" a little too early — they're technically in view but hidden behind the header. Pass `rootMargin` to `Toc` to shrink the observer's viewport from the top by the header's height, so a heading only counts as active once it clears it:

```tsx
<Toc rootMargin="-80px 0px 0px 0px">{/* … */}</Toc>
```

The value is a standard [`IntersectionObserver` `rootMargin`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver#rootmargin) string (`top right bottom left`). A negative top inset pulls the active boundary down below your header.

## Router links [#router-links]

`TocLink` is built on Base UI's [`useRender`](https://base-ui.com/react/utils/use-render), so you can project it onto your framework's link with `render` while keeping the scroll-spy wiring and active styling:

```tsx
<TocLink href="#usage" render={<Link href="#usage" />}>
  Usage
</TocLink>
```

## RTL [#rtl]

Every Appica UI component supports right-to-left layouts out of the box. Set the `dir` attribute on a container (commonly your `<html>` element) so CSS logical properties resolve correctly, and wrap your tree in `DirectionProvider` so direction-aware behavior follows the same direction.

```tsx
import { DirectionProvider } from '@appica/ui-react/providers/direction-provider'

export default function RootLayout({ children }) {
  return (
    <html lang="ar" dir="rtl">
      <body>
        <DirectionProvider dir="rtl">{children}</DirectionProvider>
      </body>
    </html>
  )
}
```

The list's rail and active indicator move to the right edge and the depth indentation flips, since both are built from CSS logical properties. For setup details and caveats, see the [RTL guide](/ui/docs/react/rtl).

<RtlPreview component="toc" />

## API reference [#api-reference]

Each part forwards every remaining prop to its underlying element (`nav`, `ul`, `li`, `a`). The tables below list the Appica additions and the most common props.

### Toc [#toc]

The navigation landmark and scroll-spy provider. Renders a `<nav aria-label="Table of contents">`.

| Prop         | <ColMinWidth width="200">Type</ColMinWidth> | Default               | <ColMinWidth width="240">Description</ColMinWidth>                                                                    |
| ------------ | ------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `rootMargin` | `string`                                    | `'0px'`               | `IntersectionObserver` `rootMargin` (`top right bottom left`) — offset the active boundary, e.g. for a sticky header. |
| `aria-label` | `string`                                    | `'Table of contents'` | Accessible name for the navigation landmark.                                                                          |
| `children`   | `ReactNode`                                 | —                     | Usually a `TocList` (with an optional heading above it).                                                              |
| `className`  | `string`                                    | —                     | Extra classes, merged via `tailwind-merge`.                                                                           |

### TocList [#toclist]

The list of entries, carrying the shared rail and sliding active indicator. Renders a `<ul role="list">`.

| Prop        | Type        | Default | Description                                 |
| ----------- | ----------- | ------- | ------------------------------------------- |
| `children`  | `ReactNode` | —       | `TocItem`s.                                 |
| `className` | `string`    | —       | Extra classes, merged via `tailwind-merge`. |

### TocItem [#tocitem]

One entry's `<li>` wrapper.

| Prop        | Type        | Default | Description                                 |
| ----------- | ----------- | ------- | ------------------------------------------- |
| `children`  | `ReactNode` | —       | A `TocLink`.                                |
| `className` | `string`    | —       | Extra classes, merged via `tailwind-merge`. |

### TocLink [#toclink]

A link to one heading. Registers its target for scroll-spy and reflects the active state. Built on Base UI's [`useRender`](https://base-ui.com/react/utils/use-render); renders an `<a>` unless you project it with `render`.

| Prop        | <ColMinWidth width="220">Type</ColMinWidth>                 | Default | <ColMinWidth width="220">Description</ColMinWidth>                                               |
| ----------- | ----------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------ |
| `href`      | `string`                                                    | —       | **Required.** The target heading, as a hash (`#id`). The `id` after `#` is the heading observed. |
| `depth`     | `number`                                                    | `2`     | Heading level (2–6); controls the indentation. Higher = deeper indent.                           |
| `render`    | <Code>ReactElement \| (props, state) => ReactElement</Code> | —       | Render as your own element (e.g. a router `Link`). The `state` exposes `active`.                 |
| `className` | <Code>string \| ((state) => string)</Code>                  | —       | Extra classes, merged via `tailwind-merge`. May be a function of the `{ active }` state.         |

The link exposes `data-active` while its heading is in view and `aria-current="true"` on the topmost active link — both available for styling.

## Accessibility [#accessibility]

* `Toc` renders a `<nav>` with a default `aria-label="Table of contents"`; override it (e.g. `"On this page"`) to suit the surrounding page. The list is a `role="list"` of plain links.
* The topmost in-view link carries `aria-current="true"`, so assistive tech announces the reader's current position.
* Links are ordinary anchors, fully keyboard-focusable, and activating one jumps to its heading — pair headings with `scroll-mt-*` so they aren't hidden under a sticky header on arrival.
* The active indicator and link transitions honour `prefers-reduced-motion`.
* Scroll-spy is presentational: it only updates `data-active`/`aria-current`, never moving focus or scrolling on its own.
