---
title: "Table of contents"
description: "Lists the headings of the page being read as links to them and marks the one whose section is being read. Where there is no room beside the text, one row names the current section and opens the same list."
url: "https://tanrei.chnotchy.com/components/table-of-contents/"
last_updated: 2026-10-23T21:17:12.000Z
---

# Table of contents

> Lists the headings of the page being read as links to them and marks the one whose section is being read. Where there is no room beside the text, one row names the current section and opens the same list.

## Overview

A table of contents shows what a long page holds and which part the reader is in. Each item is a link to a heading; sub-headings are set in under their heading. The current item has a marker on the rail and a heavier label. It is not the product’s navigation between pages: that is a navigation drawer.

On the web it is `<tanrei-table-of-contents>` around your own nested list of anchors. The anchors stay in the document, so before any script runs the page holds a plain list of links that work. The other platforms have not implemented it yet.

### Usage

**Web**

```html
<link rel="stylesheet" href="@tanrei/web/table-of-contents.css" />

<tanrei-table-of-contents label="On this page">
  <ul>
    <li><a href="#top">Overview</a></li>
    <li>
      <a href="#install">Install</a>
      <ul>
        <li><a href="#npm">npm</a></li>
      </ul>
    </li>
    <li><a href="#configure">Configure</a></li>
  </ul>
</tanrei-table-of-contents>

<script type="module">
  import '@tanrei/web/components/table-of-contents';
</script>
```

**React**

```tsx
import { TableOfContents } from '@tanrei/react';
import '@tanrei/web/table-of-contents.css';

<TableOfContents label="On this page" density="compact">
  <ul>
    {headings.map((heading) => (
      <li key={heading.id}>
        <a href={`#${heading.id}`}>{heading.text}</a>
      </li>
    ))}
  </ul>
</TableOfContents>
```

`@tanrei/web/table-of-contents.css` draws the title and the list before any script runs, and the list does not move when the script loads. The element adds the same stylesheet to the page by itself. The compact form’s chevron is a glyph of the icon set and needs the icon font.

### What you write

| Part | On the web |
| --- | --- |
| The title | The `label` attribute, in your product’s language. It also names the navigation landmark. |
| An item | An `<li>` whose first child is its `<a href>`. The label is the anchor’s text, as plain text. |
| An item’s own items | A `<ul>` after the anchor, inside the same `<li>`. |
| The item for the top of the page | An item whose `href` is `#top` or `#`. It is current until the first heading is reached. |
| A title that is a heading | A child with `slot="title"`, such as `<h2 slot="title">On this page</h2>`. `label` still names the landmark. |
| Which item is current | Nothing: the element decides. With `tracking="manual"` you set `current` to an item’s `href`. |

The items are given, not discovered. A page built by a site generator or a framework knows its headings when it is built, so the list is in the page from the start and a crawler reads it.

| Event | When | `detail` |
| --- | --- | --- |
| `currentchange` | The current item changed while `tracking` is `auto`. | `href`: the new item’s, or an empty string |
| `select` | An item was activated, before the browser follows the link. Cancel it to move the view yourself. | `href` |
| `toggle` | The compact form’s list was shown or hidden by the user. | `open` |

## Specs

- Category: Navigation
- Level: base
- Listing: default
- Page: https://tanrei.chnotchy.com/components/table-of-contents/
- Spec: https://github.com/chnotchy/tanrei/blob/main/specs/table-of-contents.spec.json

### Availability

| Design system | Has it | Notes |
| --- | --- | --- |
| Tanrei (`tanrei`) | Yes | — |
| Cupertino (Apple HIG-inspired) (`cupertino`) | Yes | — |
| M3 Expressive-like (Material 3 Expressive-inspired) (`m3-expressive-like`) | Yes | — |
| M3-like (Material 3-inspired) (`m3-like`) | Yes | — |

### Platforms

- Web: the element `<tanrei-table-of-contents>`, registered by importing `@tanrei/web` (or only its own module, `@tanrei/web/components/table-of-contents`).
- React: `TableOfContents` from `@tanrei/react`.

| Platform | Name | Status |
| --- | --- | --- |
| Web | `tanrei-table-of-contents` | lagging |
| SwiftUI | `TanreiTableOfContents` | planned |
| Jetpack Compose | `TanreiTableOfContents` | planned |
| Flutter | `TanreiTableOfContents` | planned |
| React Native | `TableOfContents` | planned |

Names a platform gives in its own idiom:

| Spec | Web | SwiftUI | Jetpack Compose | Flutter | React Native |
| --- | --- | --- | --- | --- | --- |
| `labelHidden` (prop) | `label-hidden` | `labelHidden` | `labelHidden` | `labelHidden` | `labelHidden` |
| `open` (prop) | `open` | `isOpen` | `open` | `open` | `open` |
| `currentChange` (event) | `currentchange` | `current` | `onCurrentChange` | `onCurrentChanged` | `onCurrentChange` |
| `select` (event) | `select` | `onSelect` | `onSelect` | `onSelected` | `onSelect` |
| `toggle` (event) | `toggle` | `isOpen` | `onOpenChange` | `onOpenChanged` | `onOpenChange` |

Statuses: `planned` — In the inventory. Nothing shippable exists yet. `lagging` — An implementation of an earlier spec exists; it has not caught up with the current one. `alpha` — Implemented with tests. The API may still change. `beta` — Feature complete and at parity. Only fixes are expected. `stable` — Covered by semantic versioning. `deprecated` — Still shipped; scheduled for removal.

### Anatomy

- `title` (optional): What the list is, in the product's words ("On this page"). Text above the list, and the label of the compact form's row. It names the navigation landmark. Not a heading unless the product gives one of its own.
- `list`: The items in the page's order, one column as wide as the layout gives. It has no fill and no edge of its own in the list presentation.
- `container`: One item's row: the part that shows hover, focus and press. It runs from the rail to the list's end edge at every level of nesting, and has no fill at rest.
- `label`: An item's text: the heading it leads to, set in further for each level of nesting. It wraps and is never cut.
- `rail`: One hairline along the list's start edge, as long as the list. The track the marker sits on. Decorative.
- `marker`: A heavier stroke on the rail beside the current item's row, as high as that row. With the label's heavier weight it is what tells the current item from the others. Decorative: the current state is exposed by the item.
- `disclosure-row` (optional): The compact presentation's row: the title, the name of the current section and a chevron. A button that shows and hides the list below it.
- `supporting-text` (optional): The name of the current section in the disclosure row, on one line, cut where it does not fit. Empty while no item is current.
- `icon` (optional): The disclosure row's chevron (expand_more), turned half a turn while the list is shown. Decorative.
- `focus-ring`: The indicator shown for keyboard focus, inside the focused row's container.

### Props

| Prop | Type | Default | Web attribute | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | `""` | `label` | The title, in the product's language ("On this page"): drawn above the list and in the compact form's row, and the accessible name of the navigation landmark. Tanrei ships no words of its own. Required; a table of contents without one still renders and is reported (TOC-40). |
| `labelHidden` | `boolean` | `false` | `label-hidden` | True draws no title above the list and keeps label as the landmark's name: for a layout whose own heading already says what the list is. The compact form's row always shows the title, which is that row's label. |
| `presentation` | `enum`: `list`, `compact` | `"list"` | `presentation` | How the table of contents stands. list: the title and the whole list, for a column beside the text. compact: one row that shows the title and the name of the current section, and opens the same list below it; for a window with no room beside the text. Which one a window width gets is decided by the layout that places it (an app shell, the product), which also decides where it stands and whether it stays in view. |
| `density` | `enum`: `regular`, `compact` | `"regular"` | `density` | How tall a row of the list is, in a table's, an accordion's and a tree view's words; it is not the compact presentation, and applies to the list of either presentation. regular (a 44 row in the default system) holds the pointer target, as every control of the default look does. compact (a 32 row) is for a long list of headings beside the text of a product read with a pointer: it is lower than the pointer target, which is acceptable because it is asked for by name and is never what a product gets by default. For touch a row is never lower than the touch target in either. The label's text style, the indent and the inline padding are the same in both. A design system may add values. |
| `open` | `boolean` | `false` | `open` | In the compact presentation, whether the list is shown below the row. The web sets it itself when the user opens, closes or chooses; a controlled platform leaves it to the caller. Ignored in the list presentation, where the list is always shown. |
| `current` | `string` | `""` | `current` | The href of the item whose section the person is in; empty while none is. While tracking is auto the table of contents sets it by the rule of TOC-9 and tells the product with currentChange; while tracking is manual it is the product's to set. |
| `tracking` | `enum`: `auto`, `manual` | `"auto"` | `tracking` | Who decides which item is current. auto: the table of contents, from where the items' targets are in the view that scrolls them (TOC-9 to TOC-16). manual: the product, through current — for a page whose sections are not places in one scrolling view (steps shown one at a time, a virtual list), or a product that already knows where its reader is. |

### Events

| Event | Description |
| --- | --- |
| `currentChange` | The current item changed while tracking is auto: by the person's scrolling, by an item being chosen, or by the address naming a target. Carries the new item's href, or an empty string when no item is current. Emitted exactly once per change, never for a value the product set, and never while tracking is manual. |
| `select` | An item was activated by pointer, touch, Enter or assistive technology; carries its href. Emitted before the platform follows the link. A product that moves the view itself (a router, a scroll view the platform's links do not reach) cancels it and moves. |
| `toggle` | The compact presentation's list was shown or hidden: by the user from the row, by an item being chosen, or by Escape; carries the new open. Emitted exactly once per change, when the change begins. |

### Slots

| Slot | Description |
| --- | --- |
| `title` | The product's own title content in place of the label prop's text above the list: a heading of the level the page's outline needs. Drawn in the title's text style whatever its level; its role and level are kept. label still names the landmark and is still the compact form's row label. No control. |

### Item: `item`

One heading of the page: a link to it. An item may hold items, the sub-headings of its section, which are drawn one level further in.

Names: Web `li`, SwiftUI `TanreiTableOfContentsItem`, Jetpack Compose `TanreiTableOfContentsItem`, Flutter `TanreiTableOfContentsItem`, React Native `TableOfContentsItem`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `href` | `string` | `""` | The destination: the heading's place on the page. On the web a fragment of the page's address ("#installation"); on the other platforms the identifier the product registered that section's heading under. An item whose destination is the top of the page (on the web the fragment the platform reads as the top) is the item for what comes before the first heading (TOC-10). Required and unique within the table of contents. |
| `label` | `string` | `""` | The heading's text, as plain text: what the item shows and its accessible name. A heading that holds inline code, a tag or a link is given as its text alone. Required. |

### States

`enabled`, `hovered`, `focused`, `pressed`

### Accessibility

- Role: `navigation`
- Labeling: A navigation landmark named by label ("On this page"), in the product's language; a page with a second table of contents gives each another name. Inside it a list of links, each named by its item's label, sub-headings as a list inside their heading's item. The current item carries the current-location state and nothing is added to its name. In the compact presentation the disclosure row is a button named by label, described by the current item's label, with its expanded state. The title is drawn text and the landmark's name; it is a heading only where the product gave one.
- Minimum target size: yes

| Keys | Action |
| --- | --- |
| Tab / Shift+Tab | Moves focus through the items in order, each one a stop, as through any list of links; in the compact presentation to the disclosure row, and through the items only while the list is shown. |
| Enter | On an item: follows the link to its heading. On the disclosure row: shows or hides the list. |
| Space | On the disclosure row: shows or hides the list. On an item it does what it does on any link (on the web it scrolls the page). |
| Escape | In the compact presentation, while the list is shown and focus is on the row or in the list: hides the list and returns focus to the row. |

- The current item is exposed as a location, not as a page. Every item of a table of contents leads to the page the person is already on, so the state that says "the current page" would be true of all of them and is the one the product's navigation uses for the page itself; the state for the current location within a context says what is meant: this part, of this page. The bare true value says less than either. A screen reader that does not know the value announces the item as current, which is still right.
- A change of the current item is not announced (TOC-8). It follows from the person's own scrolling or from the link they chose, and a live region would narrate every heading a reader scrolls past. The state is there when they move to the list.
- The items are plain links in the focus order, one stop each, with no arrow-key movement of their own: a table of contents is a list of links in a landmark, which assistive technology already lets people list, skip and move through, and a composite widget would take those keys away. A product places a skip link before a long one, or places the table of contents after the content in the reading order.
- Current is never told by color alone (TOC-7): the marker is a stroke heavier than the rail in primary, which the token invariants hold to 4.5:1 on every surface role of the default system (13.3:1 at the least today; 3.1:1 at the least in the shipped presets), and the label is heavier by the system's emphasis. The rail is decorative and is not held to 3:1, as a divider's line is not. Item labels are secondary text on a surface and the current label primary text, both held to 4.5:1 by the token invariants.
- The target (TOC-24), WCAG 2.5.8. A row's hit area is the list's whole width and the row's height, with no space between rows. For touch a row is drawn at least the touch target high in both densities. For a fine pointer a regular row, the default, is the pointer target high (44 in the default system). The compact density is lower than the pointer target (32), by design: it is the density a product asks for by name where a long list of headings must fit beside the text, it stays above the 24 by 24 the criterion asks, and its rows reach across the list's whole width. A product that cannot accept that uses regular.
- The items are not underlined (TOC-6). An underline is what tells a link from the text beside it in a sentence (WCAG 1.4.1); a row of a list inside a navigation landmark has no text beside it to be told from, as a navigation drawer's destination has not.
- Keyboard focus after choosing an item goes with the page (TOC-28): the next Tab starts from the chosen section. The table of contents never pulls focus back to itself and never moves it into the content.
- Text must remain readable at 200% text size: labels wrap and rows grow (TOC-33); only the current section's name in the compact row is ever cut, and it is whole in the list below it.
- Motion: the table of contents moves nothing but its own compact list and its state changes, each at once where motion is reduced (TOC-7, TOC-23, TOC-26). Whether the page glides to a heading is the product's setting, not this component's (TOC-14).
- A page with no headings has no table of contents: no empty landmark is left for assistive technology to find (TOC-32).
- How a state looks is a value decision of the active design system (a state-layer mix in Tanrei, a dim or no hover at all elsewhere). The table of contents reads the state's tokens and never computes the effect itself.

### Behaviour

Requirements in EARS form, each with its stable id.

- **TOC-1** THE table of contents SHALL lay out, in the list presentation, its title and below it, comp.table-of-contents.container.gap away, its items in one column in the order given, each item's own items directly after it and before the item that follows it, the whole as wide as the layout it is placed in; it SHALL draw no fill, no edge and no padding of its own around them, SHALL NOT place itself, keep itself in view or limit its own width, and SHALL NOT decide its presentation from the window: where it stands, that it stays while the page scrolls, and which presentation a width gets are the layout's that places it.
- **TOC-2** WHILE labelHidden is false THE table of contents SHALL draw its title above the list in comp.table-of-contents.title.typescale and comp.table-of-contents.title.color: the label's text, or the content of the title slot where the product filled it, drawn in the same style whatever its level and keeping its role and level. WHILE labelHidden is true THE table of contents SHALL draw no title in the list presentation and SHALL still name its landmark by label (TOC-31).
- **TOC-3** THE table of contents SHALL draw each item as a row whose container runs from the rail to the list's end edge at every level of nesting, at least as high as its density gives (comp.table-of-contents.row.height.regular for regular and comp.table-of-contents.row.height.compact for compact) and as high as its label needs, with corners comp.table-of-contents.row.shape, holding its label in comp.table-of-contents.label.typescale with the padding of its density above and below it (comp.table-of-contents.row.padding-block.regular for regular and comp.table-of-contents.row.padding-block.compact for compact), comp.table-of-contents.row.padding-inline after it, and before it comp.table-of-contents.row.padding-inline plus comp.table-of-contents.row.indent for each level of nesting; only the label is set in, so that every row's state layer, focus ring and marker start at the same edge and the marker of a nested item sits on the one rail. Rows follow one another with no space between them, so that no place between two rows takes no press. Density changes a row's height and nothing else: the label's text style, where it starts and the rail are the same in both.
- **TOC-4** THE table of contents SHALL wrap a label that is wider than its row onto further lines that start where its first line starts, breaking where the label's language allows a break and, for a run with no such place that is longer than the line (a long identifier, an address), inside the run; in the list it SHALL NOT cut a label, draw an ellipsis or scroll sideways, and the row SHALL grow by the lines it holds.
- **TOC-5** THE table of contents SHALL draw the rail along the list's start edge, from the top of the first row to the bottom of the last, comp.table-of-contents.rail.width wide in comp.table-of-contents.rail.color, once for the whole list and not once per level, and SHALL hide it from assistive technology.
- **TOC-6** THE table of contents SHALL make each item the platform's own link to its href (on the web an anchor element in the document itself, kept there and not copied into a shadow root; on the other platforms an element with the platform's link semantics), exposing the link role, so that whatever the platform gives a link — on the web its context menu, copying its address, opening it in a new tab, a crawler that reads it, and a list of links that works before any script has run — is the platform's; it SHALL draw an item's label without an underline, because an item is a row of a list in a navigation landmark and is not told from text beside it, and SHALL NOT draw a visited state.
- **TOC-7** WHILE an item is current THE table of contents SHALL draw that item's row from the tokens of the current part in place of the item part's, its label at the weight of comp.table-of-contents.label.typescale plus sys.typescale.emphasis.body, and the marker on the rail beside that row, as high as the row, comp.table-of-contents.marker.width wide in comp.table-of-contents.marker.color, covering the rail there; at most one item SHALL be drawn as current, an item that holds the current item SHALL be drawn as any other item, and no row's lines SHALL break at another place and no row SHALL change its height because an item became or stopped being current, so that the list does not move while the page scrolls. WHEN the current item changes THE table of contents SHALL change the two rows over comp.table-of-contents.motion.state-duration with comp.table-of-contents.motion.state-easing, and at once where motion is reduced; the marker SHALL NOT travel between rows.
- **TOC-8** THE table of contents SHALL expose the current item as the current location within its set (on the web aria-current with the value location; a platform without that state, the selected state) and every other item without it; WHEN the current item changes THE table of contents SHALL announce nothing, so that scrolling a page is not narrated.
- **TOC-9** WHILE tracking is auto THE table of contents SHALL take as current the last item, in the page's order, whose target has reached the line, where an item's target is the place its href leads to in the view that scrolls it, the line is where that target comes to rest when a link moves the view to it — the view's start edge, below whatever the product keeps fixed there, plus the space the target keeps before itself (PRS-23) — and a target has reached the line when its start edge is at the line or before it, within one unit of the platform's layout; WHILE no target has reached the line THE table of contents SHALL have no current item. It SHALL NOT take an item as current because its target is merely in view.
- **TOC-10** WHERE an item's destination is the top of the page (on the web a fragment the platform reads as the top of the document; elsewhere the identifier the product registered for the start of the content) THE table of contents SHALL treat its target as having always reached the line, so that it is current from the start of the page until the first heading reaches the line and the part before the first heading has a name.
- **TOC-11** WHILE tracking is auto, the view has room to scroll and is at the end of its range THE table of contents SHALL take the last item as current, whether or not its target has reached the line, because a last section shorter than the view never can; WHEN the view leaves the end of its range THE rule of TOC-9 SHALL apply again. A view with no room to scroll SHALL follow TOC-9 and TOC-10 alone.
- **TOC-12** WHEN the user activates an item by pointer, touch, Enter or assistive technology THE table of contents SHALL emit select exactly once with that item's href and then, unless the product cancelled it, let the platform follow the link as it follows any link (on the web the browser moves the view and the address; the table of contents SHALL NOT scroll the view itself and SHALL NOT hold the navigation back for sys.motion.duration.press-hold); WHILE tracking is auto it SHALL make that item current at once and keep it current while the view moves to the target and after it has arrived — also where the target cannot reach the line — until the view next moves by the person's own scrolling (a wheel, a drag, a key, the scroll bar), from which TOC-9 to TOC-11 apply again. While the view moves to the target THE table of contents SHALL NOT mark the items it passes.
- **TOC-13** WHEN the table of contents first appears while the page's address names the target of one of its items, or the address comes to name one while it is shown (a link elsewhere on the page, a heading's own anchor, a step back or forward in the history), THE table of contents SHALL, WHILE tracking is auto, make that item current and keep it as TOC-12 keeps a chosen item; it SHALL draw that item as current in its first drawing where the platform knows the address then, and SHALL NOT show another item as current first.
- **TOC-14** THE table of contents SHALL add no motion of the view of its own: whether the view glides or jumps to a target is the platform's and the product's (on the web the page's own scroll behaviour, which the product turns off where motion is reduced), and TOC-12 holds for either. It SHALL NOT write the address, add an entry to the history or move the view because the person scrolled.
- **TOC-15** WHILE tracking is manual THE table of contents SHALL draw and expose as current the item whose href equals current, and none WHILE current is empty; it SHALL NOT observe the view, SHALL NOT change current and SHALL NOT emit currentChange, and SHALL still emit select and follow links as TOC-12 says. WHILE tracking is auto and the product sets current THE table of contents SHALL draw that item as current until the rule next gives another.
- **TOC-16** WHEN the place of a target changes without the person scrolling — content above it loads or is folded away, the window is resized, the text size changes, an item is added or removed — THE table of contents SHALL, WHILE tracking is auto, decide the current item again by TOC-9 to TOC-11, keeping an item that TOC-12 or TOC-13 is holding. IF an item's href leads to no place on the page THE table of contents SHALL still draw it as a link, SHALL never take it as current by the rule, and in debug builds report it.
- **TOC-17** WHEN the current item changes while its row is not wholly inside the visible part of a scrolling region that holds the list — the table of contents' own in the compact presentation (TOC-27), or the layout's — THE table of contents SHALL scroll that region, and no other, by the least distance that shows the row whole, at once where motion is reduced; it SHALL NOT scroll the view that holds the targets, and SHALL NOT scroll the list WHILE the pointer is over it or keyboard focus is inside it, so that a row is not moved from under a hand.
- **TOC-18** WHILE an item's row or the disclosure row is hovered, keyboard-focused or pressed THE table of contents SHALL render that row's container, label and opacity from the component tokens of the highest-precedence active state of its part (item, current or disclosure), with precedence pressed over focused over hovered; hover and press anywhere on a row SHALL answer on that row's container; focus gained by pointer or touch SHALL NOT select the focused state.
- **TOC-19** WHEN a row receives keyboard focus THE table of contents SHALL show the focus ring without animation inside that row's container, following comp.table-of-contents.row.shape, sys.focus.outer-width wide with the inner stroke of sys.focus.inner-width where the system has one, so that the ring is not cut by the neighbouring rows or by the edge of a scrolling region; focus gained by pointer or touch SHALL NOT show it.
- **TOC-20** WHEN an item's row is pressed by pointer, touch or Enter, or the disclosure row by pointer, touch, Enter or Space, THE table of contents SHALL show the pressed state without delay and SHALL keep it visible for at least sys.motion.duration.press-hold, even when the press ends sooner. Space on an item does what it does on any link (on the web it scrolls the page) and SHALL NOT show the pressed state.
- **TOC-21** WHILE pressed THE table of contents SHALL answer as sys.behavior.pressed-feedback names: overlay applies the pressed tokens to the whole container at once; ripple grows the pressed state layer from the point of contact (from the center for keyboard activation); dim applies the pressed opacity to the whole control and draws no state layer; none draws no answer to the press. Under ripple and dim only the container color stays with the state under the press; label, opacity and shape still come from the pressed tokens. Under none every part stays with the state under the press. The ripple grows over comp.table-of-contents.motion.feedback-duration with motion.state-easing, fades over motion.state-duration with motion.state-easing once the press has ended and press-hold has passed, and appears whole where motion is reduced.
- **TOC-22** THE table of contents SHALL ease its rows' container corners and the compact form's corners by sys.shape.corner-smoothing (0 is a quarter circle, 0.6 the iOS continuous corner; a platform with only the OS curve draws it for any amount above 0) and show its keyboard focus as sys.behavior.focus-style names (ring or glow); a platform that cannot draw a choice SHALL fall back to the first value and state that in its README.
- **TOC-23** THE table of contents SHALL change a row between its states over comp.table-of-contents.motion.state-duration with comp.table-of-contents.motion.state-easing, and at once where motion is reduced.
- **TOC-24** THE table of contents SHALL make each row its hit area across the list's whole width, whatever the level of its label, with no gap between the hit areas of two rows; WHERE input is touch, or the platform cannot tell the input type, a row SHALL be at least sys.target.touch high, drawn so, in both densities, because rows that touch cannot extend a hit area over each other; for a fine pointer a row SHALL be at least sys.target.pointer high WHILE density is regular, and WHILE density is compact as high as its density gives (comp.table-of-contents.row.height.compact), the one place where a row of the list is lower than the pointer target: the density is asked for by name and is never the default; the disclosure row SHALL be at least comp.table-of-contents.compact.row.height high and never lower than sys.target.touch for touch or sys.target.pointer for a fine pointer.
- **TOC-25** WHILE presentation is compact THE table of contents SHALL draw one block as wide as its layout, filled with comp.table-of-contents.compact.container.color with corners comp.table-of-contents.compact.container.shape, holding first the disclosure row: at least comp.table-of-contents.compact.row.height high, comp.table-of-contents.compact.row.padding-inline at its start and end, and in reading order, comp.table-of-contents.compact.row.gap apart, the title in comp.table-of-contents.title.typescale in the disclosure part's label color, the current item's label in comp.table-of-contents.compact.supporting-text.typescale and comp.table-of-contents.compact.supporting-text.color taking the room that is left, on one line and cut with an ellipsis at the end of its own direction where it does not fit, and at the row's end the icon set's expand_more in a box of comp.table-of-contents.compact.icon.size in comp.table-of-contents.compact.icon.color, turned half a turn WHILE open is true. WHILE no item is current the row SHALL show the title and the chevron alone. The title SHALL NOT be cut before the current item's label has been cut to nothing.
- **TOC-26** WHEN the user activates the disclosure row by pointer, touch, Enter or Space THE table of contents SHALL emit toggle exactly once with the new open and, where it owns open, show or hide the list: WHILE open is true the list of TOC-3 to TOC-7 is drawn below the row inside the block, comp.table-of-contents.compact.list.padding-inline from the block's start and end edges and with comp.table-of-contents.compact.list.padding-block below it, the block growing and what follows it in the layout moving; WHILE open is false the list is not drawn, not exposed to assistive technology and not focusable. The change SHALL take comp.table-of-contents.motion.disclosure-duration with comp.table-of-contents.motion.disclosure-easing, the chevron turning with it, and happen at once where motion is reduced. The table of contents SHALL NOT draw a scrim, SHALL NOT keep focus inside itself and SHALL NOT close because a press landed outside it: whether an open list that the layout keeps over the text closes on such a press is that layout's, through open.
- **TOC-27** WHILE presentation is compact and open is true THE table of contents SHALL NOT let its block be taller than the room its layout gives it: where the list needs more, the list SHALL scroll inside the block while the disclosure row stays in place, and WHEN the list is shown THE table of contents SHALL bring the current item's row into its visible part without a motion (TOC-17).
- **TOC-28** WHEN the user activates an item WHILE presentation is compact THE table of contents SHALL hide the list (emitting toggle with false) and follow the link as TOC-12 says, and SHALL NOT move keyboard focus to the disclosure row: the next move of focus continues from the section that was chosen, as it does after any link to a place on the page.
- **TOC-29** WHEN the user presses Escape WHILE presentation is compact, open is true and keyboard focus is on the disclosure row or inside the list THE table of contents SHALL hide the list, emit toggle with false, and move focus to the disclosure row where it was inside the list. Moving focus out of the table of contents by Tab SHALL NOT hide the list.
- **TOC-30** WHEN presentation changes while the table of contents is shown THE table of contents SHALL keep its current item, draw the new presentation at once without a motion of its own, set open to false without emitting toggle, and keep keyboard focus on the item it was on where that item is still shown, moving it to the disclosure row where it is not.
- **TOC-31** THE table of contents SHALL expose itself as a navigation landmark named by label, holding its items as a list in which an item's own items are a list inside that item, each item one link named by its label with its position in its list; the rail, the marker and the chevron SHALL be hidden from assistive technology. WHILE presentation is compact THE table of contents SHALL expose the disclosure row as one button named by label, described by the current item's label, with its expanded or collapsed state, controlling the list. The title SHALL be exposed once: as the landmark's name, and as a heading only where the product gave a heading in the title slot.
- **TOC-32** IF the table of contents has no items THE table of contents SHALL draw nothing, take no space and expose nothing, in either presentation — no title, no landmark and no disclosure row — so that a page without headings has no empty list. IF it has one item THE table of contents SHALL render as it does for several.
- **TOC-33** WHILE the text size is enlarged THE table of contents SHALL grow its title, its labels and the disclosure row's texts with it, keep its paddings, comp.table-of-contents.row.indent, comp.table-of-contents.rail.width and comp.table-of-contents.marker.width, wrap its labels as TOC-4 says and let its rows and the disclosure row grow in height; nothing SHALL be cut in the list, and at twice the text size every label SHALL still be readable in a column that held it at the ordinary size.
- **TOC-34** WHERE the platform draws with a person's own forced colors (the web's forced-colors mode, and a platform's equivalent where it has one) THE table of contents SHALL draw its items' labels in the platform's link color, the current item's label, the title and the disclosure row's texts in the platform's text color, the rail and the marker in the platform's text color with the marker kept comp.table-of-contents.marker.width wide and drawn as a stroke the mode does not remove, the compact form with an edge in the platform's text color in place of its fill, and its focus ring in the platform's highlight color.
- **TOC-35** WHERE the layout direction is right-to-left THE table of contents SHALL mirror its layout and apply its paddings by logical start and end: the rail and the marker at the right edge, each level's label set in further from the right, lines aligned to the right, and in the disclosure row the title at the right, the current item's label after it and the chevron at the left. The chevron SHALL NOT be mirrored: it turns, and shows no direction along the line.
- **TOC-36** THE table of contents SHALL take its layout direction from where it is used — the direction of the nearest ancestor that sets one, and the platform's own where none does — and SHALL have no direction setting of its own; WHEN that direction changes while the table of contents is shown THE table of contents SHALL draw itself for the new direction at once, without a motion of its own, keeping its current item, its open and its focus.
- **TOC-37** THE table of contents SHALL lay out its title, each item's label and the current item's label in the disclosure row each as text of its own direction, isolated from the text around it: it takes the base direction of its first strong character, or the layout direction where it has none, so that a left-to-right name, a version or a number keeps its own order in a right-to-left layout and reorders nothing beside it; THE table of contents SHALL still place each text and align its lines by the layout direction, so that the labels of one level start at one edge whatever language each is in.
- **TOC-38** WHILE the system's icon set cannot yet draw the chevron (its font is still loading) THE table of contents SHALL draw nothing in its place, never the glyph's name as text, and SHALL keep its box, so that the disclosure row's texts do not move when the glyph appears.
- **TOC-39** WHEN presentation or tracking names a value the table of contents does not have THE table of contents SHALL behave as the default value (list, auto), and WHEN density names a value that the active design system does not have it SHALL render with the default density's tokens; in debug builds it SHALL report the unknown value. WHEN density changes while the table of contents is shown THE table of contents SHALL draw the new heights at once, keeping its current item, its open state and its focus. IF current names no item's href WHILE tracking is manual THE table of contents SHALL draw no item as current and in debug builds report it. IF two items have the same href THE table of contents SHALL render both, take the first as the one that can be current, and in debug builds report it. IF items nest more than three levels deep THE table of contents SHALL still render them, setting each level's label in by comp.table-of-contents.row.indent, and in debug builds report the depth.
- **TOC-40** IF label is empty THE table of contents SHALL still render, drawing no title and, in the compact presentation, a disclosure row that shows the current item's label alone, and in debug builds report that the landmark and the row have no name, unless the product names the landmark (TOC-41). IF an item's label is empty THE table of contents SHALL NOT draw that item or its items, and in debug builds report it.
- **TOC-41** WHERE the platform has ARIA attributes (the web), WHEN the product sets an aria- attribute on the host, by the attribute or by the ARIA property that reflects it, THE table of contents SHALL move it to the data-aria- attribute of the same name, so that the host is no second node with its name, description or state, and SHALL expose it on its navigation landmark — an id reference by the elements it names in the host's tree or a tree around it, a description after its own. WHILE label is not empty it is the landmark's name, over a name given so; WHILE label is empty, a name given so names the landmark, and the compact disclosure row by that name followed by the current item's label it shows, so that the row's name holds what it draws; neither is then reported as having no name (TOC-40). WHEN the product removes the aria- attribute or its data-aria- twin, or sets the ARIA property that reflects it (ariaLabel, ariaDescribedByElements…) to null, THE table of contents SHALL take it off that element.

### Tokens

As written in the token documents (DTCG): an alias in braces names the token it takes its value from, which each design system resolves. On the web each token is a CSS custom property: `--tanrei` and the name after `tanrei`, joined by hyphens (`tanrei.comp.table-of-contents.…` → `--tanrei-comp-table-of-contents-…`).

#### `tanrei.comp.table-of-contents`

| Token | Type | Value |
| --- | --- | --- |
| `comp.table-of-contents.container.gap` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.table-of-contents.title.typescale` | `typography` | `{tanrei.sys.typescale.label}` |
| `comp.table-of-contents.title.color` | `color` | `{tanrei.sys.color.on-surface}` |
| `comp.table-of-contents.label.typescale` | `typography` | `{tanrei.sys.typescale.body-small}` |
| `comp.table-of-contents.row.height.regular` | `dimension` | `44px` |
| `comp.table-of-contents.row.height.compact` | `dimension` | `32px` |
| `comp.table-of-contents.row.padding-inline` | `dimension` | `{tanrei.sys.space.space150}` |
| `comp.table-of-contents.row.padding-block.regular` | `dimension` | `12px` |
| `comp.table-of-contents.row.padding-block.compact` | `dimension` | `6px` |
| `comp.table-of-contents.row.indent` | `dimension` | `{tanrei.sys.space.space150}` |
| `comp.table-of-contents.row.shape` | `dimension` | `{tanrei.sys.shape.corner.extra-small}` |
| `comp.table-of-contents.rail.width` | `dimension` | `{tanrei.sys.border.hairline}` |
| `comp.table-of-contents.rail.color` | `color` | `{tanrei.sys.color.outline-variant}` |
| `comp.table-of-contents.marker.width` | `dimension` | `{tanrei.sys.border.thick}` |
| `comp.table-of-contents.marker.color` | `color` | `{tanrei.sys.color.primary}` |
| `comp.table-of-contents.compact.container.color` | `color` | `{tanrei.sys.color.surface-container}` |
| `comp.table-of-contents.compact.container.shape` | `dimension` | `{tanrei.sys.shape.corner.small}` |
| `comp.table-of-contents.compact.row.height` | `dimension` | `48px` |
| `comp.table-of-contents.compact.row.padding-inline` | `dimension` | `{tanrei.sys.space.space200}` |
| `comp.table-of-contents.compact.row.gap` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.table-of-contents.compact.supporting-text.typescale` | `typography` | `{tanrei.sys.typescale.body-small}` |
| `comp.table-of-contents.compact.supporting-text.color` | `color` | `{tanrei.sys.color.on-surface-variant}` |
| `comp.table-of-contents.compact.icon.size` | `dimension` | `{tanrei.sys.icon.small}` |
| `comp.table-of-contents.compact.icon.color` | `color` | `{tanrei.sys.color.on-surface-variant}` |
| `comp.table-of-contents.compact.list.padding-block` | `dimension` | `{tanrei.sys.space.space100}` |
| `comp.table-of-contents.compact.list.padding-inline` | `dimension` | `{tanrei.sys.space.space200}` |
| `comp.table-of-contents.item.container.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.table-of-contents.item.label.color` | `color` | `{tanrei.sys.color.on-surface-variant}` |
| `comp.table-of-contents.item.state-layer.color` | `color` | `{tanrei.comp.table-of-contents.item.label.color}` |
| `comp.table-of-contents.current.container.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.table-of-contents.current.label.color` | `color` | `{tanrei.sys.color.on-surface}` |
| `comp.table-of-contents.current.state-layer.color` | `color` | `{tanrei.comp.table-of-contents.current.label.color}` |
| `comp.table-of-contents.disclosure.container.color` | `color` | `{"colorSpace":"srgb","components":[0,0,0],"alpha":0,"hex":"#000000"}` |
| `comp.table-of-contents.disclosure.label.color` | `color` | `{tanrei.sys.color.on-surface}` |
| `comp.table-of-contents.disclosure.state-layer.color` | `color` | `{tanrei.comp.table-of-contents.disclosure.label.color}` |
| `comp.table-of-contents.motion.state-duration` | `duration` | `{tanrei.sys.motion.role.state.large}` |
| `comp.table-of-contents.motion.state-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.state}` |
| `comp.table-of-contents.motion.feedback-duration` | `duration` | `{tanrei.sys.motion.role.feedback}` |
| `comp.table-of-contents.motion.disclosure-duration` | `duration` | `{tanrei.sys.motion.role.disclosure}` |
| `comp.table-of-contents.motion.disclosure-easing` | `cubicBezier` | `{tanrei.sys.motion.easing.spatial}` |

Per-state tokens, valued by the design system's pattern rules (not written by hand):

- `comp.table-of-contents.<variant>.<state>.<property>` for the variants `item`, `current`, `disclosure`, the states `hovered`, `focused`, `pressed` and the properties `container.color`, `label.color`, `state-layer.color`.
- `comp.table-of-contents.<state>.<property>` for the states `hovered`, `focused`, `pressed` and the properties `opacity`.

## Guidelines

### Is it a table of contents

Use it on a page longer than a screen that is divided by headings. A page with no headings gets none: with no items the component draws nothing and leaves no empty landmark. For moving between pages use a navigation drawer; for the steps of one task use steps.

### What the layout decides

The component fills the width it is given and decides nothing about its place. The layout that holds it decides:

- where it stands, and that it stays in view while the page scrolls (a sticky column, for example);
- which presentation a window gets: set `presentation="compact"` where there is no room beside the text;
- a scrolling column around a long list. The component then keeps its current row in view inside that column.

### Density

| `density` | A row of one line | Use for |
| --- | --- | --- |
| `regular` (default) | 44, the pointer target | Most pages. |
| `compact` | 32 | A long list of headings beside the text, in a product used with a pointer. |

A compact row is lower than the pointer target. That is acceptable because it is asked for by name and is never the default. For touch a row is the touch target high in both densities. The text style and the indent are the same in both.

### Which item is current

The current item is the last one, in the page’s order, whose heading has reached the place where the browser rests a heading it is sent to. The component reads that place from your CSS:

```css
html { scroll-padding-top: 4rem; }     /* what you keep fixed at the top */
h2, h3 { scroll-margin-top: 1.5rem; }  /* the space a heading keeps above itself */
```

| Case | Current is |
| --- | --- |
| Before the first heading | None, or the item for the top of the page where you gave one. |
| At the end of the page | The last item. A last section shorter than the window could never reach the line. |
| An item was chosen | That item, at once, until the reader next scrolls by hand. |
| The address names a heading | That item, held the same way. |
| Content above a heading loads or folds | Decided again. |

The component never scrolls the page and never writes the address. Whether the page glides or jumps to a heading is your `scroll-behavior`.

Set `tracking="manual"` where the sections are not places in one scrolling view, such as steps shown one at a time, and set `current` yourself.

### The compact form

`presentation="compact"` is one filled block with a row: the title, the name of the current section and a chevron. The row is a button that shows the same list below it.

- Choosing an item closes the list and follows the link.
- Escape closes it and returns focus to the row.
- Give the element a `max-block-size`: the list then scrolls inside and the row stays in place.
- It is not an overlay. It draws no scrim and does not close on a press outside. A layout that keeps it over the text and wants that sets `open` to `false` itself.

Until the script has loaded, the compact form shows its whole list. A layout that must not show that for a moment hides `tanrei-table-of-contents[presentation="compact"]:not(:defined)`.

### Do

- Write `label` in your product’s language. A page with two tables of contents gives each its own name.
- Give the headings you want shown: usually the second and third level.
- Give an item for the top of the page where the text before the first heading is long.

### Don’t

- Don’t put a control, a tag or an icon in an item. A label is the heading’s text.
- Don’t nest more than three levels: the labels of a narrow column run out of room.
- Don’t use it as the product’s navigation. Its current state says “this part of this page”, not “this page”.

## Accessibility

### What the component handles

- It is a navigation landmark named by `label`, holding nested lists of links.
- The current item carries `aria-current="location"`. A change of it is not announced, so scrolling is not narrated.
- Every item is a link in the focus order, one stop each. Enter follows it. There is no arrow-key movement to learn.
- The rail, the marker and the chevron are hidden from assistive technology.
- The title is exposed once, as the landmark’s name. It is a heading only where you gave one in the `title` slot.
- The compact row is a button named by `label` and described by the current section’s name, with its expanded state.
- After an item is chosen, focus stays with the page: the next Tab continues from the chosen heading.
- The focus ring is drawn inside the row, so a scrolling column does not clip it.
- Current is not told by color alone: the marker is a stroke and the label is heavier.
- The list does not move when the current item changes.
- At 200% text labels wrap and rows grow. Only the current section’s name in the compact row is ever cut.
- In forced colors items are the link color, and the current label, the rail and the marker are the text color.
- An empty `label`, an item without a label, two items with one `href`, an `href` that leads nowhere, an unknown value and more than three levels are reported in debug builds.

### What you need to do

- Give every heading you list an `id`, and write the same fragment in the item’s `href`.
- Declare what you keep fixed at the top with `scroll-padding-top`, so that a heading is not hidden under it when a link moves the page there.
- Turn smooth scrolling off where the reader asks for reduced motion.
- Place a long table of contents after the content in the reading order, or give a skip link before it.
