Skip to content

Table of contents

Links to the sections of the current page, usually in a side region of a long page.

Code
demo.tsx
import {TableOfContents} from '@earendil-works/elanor-react';

export default function TableOfContentsDemo() {
  return (
    <TableOfContents.Root>
      <TableOfContents.List>
        <TableOfContents.Item>
          <TableOfContents.Link href="#usage">Usage</TableOfContents.Link>
        </TableOfContents.Item>
        <TableOfContents.Item>
          <TableOfContents.Link href="#current-section">Current section</TableOfContents.Link>
        </TableOfContents.Item>
        <TableOfContents.Item>
          <TableOfContents.Link href="#api">API</TableOfContents.Link>
        </TableOfContents.Item>
      </TableOfContents.List>
      <TableOfContents.Indicator />
    </TableOfContents.Root>
  );
}

Usage

import {TableOfContents} from '@earendil-works/elanor-react';

<TableOfContents.Root>
  <TableOfContents.List>
    <TableOfContents.Item>
      <TableOfContents.Link href="#usage" active>
        Usage
      </TableOfContents.Link>
    </TableOfContents.Item>
  </TableOfContents.List>
  <TableOfContents.Indicator />
</TableOfContents.Root>;

The demo links point to sections of this page. Root renders a nav named “Table of contents”. List renders an ordered list, at the top and for nested entries.

Current section

active marks the section in view with aria-current="location": its text turns semantic.color.text.primary, and the Indicator, a bar on the list’s start edge, marks it. When active moves to another entry, the indicator slides there and resizes with semantic.transition.movement.tableOfContents.indicator, overshooting slightly; instantly when the reader prefers reduced motion. The application decides which section is current, for example with an IntersectionObserver. In the demo, choosing an entry makes it current.

Code
active.tsx
import {TableOfContents} from '@earendil-works/elanor-react';
import {useState} from 'react';

const sections = [
  {id: 'usage', title: 'Usage'},
  {id: 'current-section', title: 'Current section'},
  {id: 'nested-sections', title: 'Nested sections'},
  {id: 'api', title: 'API'},
];

export default function TableOfContentsActive() {
  const [active, setActive] = useState('current-section');
  return (
    <TableOfContents.Root>
      <TableOfContents.List>
        {sections.map((section) => (
          <TableOfContents.Item key={section.id}>
            <TableOfContents.Link
              href={`#${section.id}`}
              active={section.id === active}
              onClick={() => setActive(section.id)}
            >
              {section.title}
            </TableOfContents.Link>
          </TableOfContents.Item>
        ))}
      </TableOfContents.List>
      <TableOfContents.Indicator />
    </TableOfContents.Root>
  );
}

Nested sections

Put another List inside an Item, after its Link, for subsections. Nested entries indent only their text, so the bar stays on the same edge.

Code
nested.tsx
import {TableOfContents} from '@earendil-works/elanor-react';

export default function TableOfContentsNested() {
  return (
    <TableOfContents.Root>
      <TableOfContents.List>
        <TableOfContents.Item>
          <TableOfContents.Link href="#usage">Usage</TableOfContents.Link>
        </TableOfContents.Item>
        <TableOfContents.Item>
          <TableOfContents.Link href="#current-section">Current section</TableOfContents.Link>
        </TableOfContents.Item>
        <TableOfContents.Item>
          <TableOfContents.Link href="#nested-sections">Nested sections</TableOfContents.Link>
          <TableOfContents.List>
            <TableOfContents.Item>
              <TableOfContents.Link href="#nested-sections" active>
                Subsections
              </TableOfContents.Link>
            </TableOfContents.Item>
          </TableOfContents.List>
        </TableOfContents.Item>
      </TableOfContents.List>
      <TableOfContents.Indicator />
    </TableOfContents.Root>
  );
}

For layouts without room for the list beside the content, Menu collapses the table of contents into a bar with a dropdown. MenuTrigger shows the current section; MenuPopup holds the same List, Item, and Link parts, which look like dropdown items there, and choosing a link closes it. The list also closes when the bar’s scroll container scrolls. Opening it moves focus to the first link, or to the list when opened by touch, without scrolling the page.

Code
menu.tsx
import {TableOfContents} from '@earendil-works/elanor-react';
import {useState} from 'react';

const sections = [
  {id: 'usage', title: 'Usage'},
  {id: 'current-section', title: 'Current section'},
  {id: 'nested-sections', title: 'Nested sections'},
  {id: 'menu', title: 'Menu'},
];

export default function TableOfContentsMenuDemo() {
  const [active, setActive] = useState('menu');
  const current = sections.find((section) => section.id === active);
  return (
    <TableOfContents.Menu>
      <TableOfContents.MenuTrigger>{current?.title}</TableOfContents.MenuTrigger>
      <TableOfContents.MenuPopup>
        <TableOfContents.List>
          {sections.map((section) => (
            <TableOfContents.Item key={section.id}>
              <TableOfContents.Link
                href={`#${section.id}`}
                active={section.id === active}
                onClick={() => setActive(section.id)}
              >
                {section.title}
              </TableOfContents.Link>
            </TableOfContents.Item>
          ))}
        </TableOfContents.List>
      </TableOfContents.MenuPopup>
    </TableOfContents.Menu>
  );
}
<TableOfContents.Menu>
  <TableOfContents.MenuTrigger>Current section</TableOfContents.MenuTrigger>
  <TableOfContents.MenuPopup>
    <TableOfContents.List />
  </TableOfContents.MenuPopup>
</TableOfContents.Menu>

Keep the trigger’s text in sync with the active link. Assistive technology hears “Table of contents: “ before it; change that with the trigger’s label, and the list’s name with the popup’s label.

The menu is a bar with separators above and below that sticks to the top of its scroll container, the document or a scrolling ancestor such as a drawer, on semantic.number.layer.floating, with a shadow that fades in as content scrolls beneath it. To stick below a header, set inset-block-start in your CSS, such as calc(var(--semantic-dimension-size-top-navigation) - var(--semantic-dimension-border-width-small)) below a top navigation: the bar sticks one border width higher, so the header’s border covers its top separator.

Static rendering

On pages rendered on the server without React in the browser, the behaviors follow the page instead of the application: the static root marks the section in view and moves the Indicator, and the static menu shows it in its trigger. Lists, items, links, and the indicator stay the same.

<TableOfContents.StaticRoot>
  <TableOfContents.List>…</TableOfContents.List>
  <TableOfContents.Indicator />
</TableOfContents.StaticRoot>

<TableOfContents.StaticMenu>
  <TableOfContents.StaticMenuTrigger>Overview</TableOfContents.StaticMenuTrigger>
  <TableOfContents.StaticMenuPopup>
    <TableOfContents.List>…</TableOfContents.List>
  </TableOfContents.StaticMenuPopup>
</TableOfContents.StaticMenu>
import {
  createPopover,
  createTableOfContents,
  createTableOfContentsMenu,
  enhance,
} from '@earendil-works/elanor-react';

enhance({
  'table-of-contents': createTableOfContents,
  'table-of-contents-menu': createTableOfContentsMenu,
  popover: createPopover,
});

A section is in view once its heading reaches the scroll-padding-top of the headings’ scroll container, the document or a scrolling ancestor, and the last one at the end of it. The menu closes when a link is chosen or the page scrolls, and its bar’s shadow fades in as content scrolls beneath it.

API

TableOfContents.Root

A nav named "Table of contents". Put a List of Items inside, and an Indicator after it to mark the active entry with a bar that moves.

Renders <nav>. Accepts its HTML attributes and ref.

TableOfContents.List

The ordered list of entries, at the top and nested inside an Item after its Link.

Renders <ol>. Accepts its HTML attributes and ref.

TableOfContents.Item

Renders <li>. Accepts its HTML attributes and ref.

PropTypeDefaultDescription
activebooleanfalseMarks the section in view and sets aria-current="location".

Renders <a>. Also accepts its HTML attributes and ref.

TableOfContents.Indicator

A bar that marks the active entry and moves to the next one when active changes, like Tabs' indicator. It measures the active link and sets --active-entry-top and --active-entry-height; it stays hidden until it has measured, and the active entry draws its own bar until then. Place it in Root, after the List.

Renders <span>. Accepts its HTML attributes, ref, and render.

TableOfContents.Menu

Renders <div>. Accepts its HTML attributes, ref, and render.

TableOfContents.MenuTrigger

Shows the current section and opens the list.

PropTypeDefaultDescription
childrenReactNode-The current section, shown in the trigger.
labelstring'Table of contents'Read before the current section by assistive technology.

Also accepts Base UI Popover.Trigger props.

TableOfContents.MenuPopup

The sections, in a nav under the trigger. Put a List of Items inside.

PropTypeDefaultDescription
labelstring'Table of contents'Accessible name of the popup and the list's nav.

Also accepts HTML attributes and ref.

TableOfContents.StaticRoot

A table of contents for pages without React in the browser; createTableOfContents marks the section in view.

PropTypeDefaultDescription
data-elanorstring-The page's own behaviors for this element, attached after the component's.

Also accepts HTML attributes and ref.

TableOfContents.StaticMenu

The table of contents as a menu in a sticky bar, for pages without React in the browser; createTableOfContentsMenu, createPopover, and createTableOfContents make it work.

PropTypeDefaultDescription
data-elanorstring-The page's own behaviors for this element, attached after the component's.

Renders <div>. Also accepts its HTML attributes and ref.

TableOfContents.StaticMenuTrigger

PropTypeDefaultDescription
childrenReactNode-The current section, shown in the trigger.
labelstring'Table of contents'Read before the current section by assistive technology.

Renders <button>. Also accepts its HTML attributes and ref.

TableOfContents.StaticMenuPopup

PropTypeDefaultDescription
labelstring'Table of contents'Accessible name of the popup and the list's nav.
data-elanorstring-The page's own behaviors for the list's nav, attached after the component's.

Also accepts HTML attributes and ref.