Skip to content
menumenu tanreiexpand_morecontrasttranslate
tanreiexpand_more
Getting startedWebReactSwiftUIJetpack ComposeFlutterReact NativePresetsYour own design systemUsing Tanrei with AI agentsBrandDesign tokensDesign systems and customizationTheme StudioColorTypographyIconsShapeElevationMotionInteraction statesBehaviourLayoutAccessibilityWordsColor tokensTypescale tokensShape tokensElevation tokensMotion tokensState tokensSpace and measure tokensComponentsButtonMain action buttonSegmented buttonButton groupButton with menuToolbarCopy buttonCheckboxRadio buttonSwitchChipMenuSliderSlider fieldCalendarDateTimePickerField groupRadio groupCheckbox groupSelectSelect fieldMenubarFile uploadFile picker AdvancedDrop zoneText input AdvancedText fieldChip fieldFieldCombobox AdvancedCombobox fieldNumber input AdvancedNumber fieldColor input AdvancedColor fieldOne-time-code input AdvancedOne-time-code fieldEmbedded text input AdvancedChat composerCardDividerModal layer AdvancedDialogListList rowBottom sheetSide sheetCarouselCode blockTablePopoverStepsAccordionDescription listFigureApp shellPage headerTree viewTimelineImage previewHandleResizable frameScroll areaBadgeProgress indicatorMeterNumber tickerLoading indicatorSnackbarTooltipKeyboard shortcutTagInline codeCalloutAvatarEmpty stateTag groupSkeletonFile itemSwatchMarquee textTabsTab viewApp barNavigation barNavigation drawerSearchLinkPaginationPrevious and next linksStepperBreadcrumbsTable of contentsLink cardFAB menuStatusHow Tanrei is builtContributingLicense and attribution
HomeComponentsTable of contents

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.

content_copyView as Markdown
Category
Navigation
Tokens
comp.table-of-contents
Level
Base

Overview

Web
tanrei-table-of-contents
schedule
SwiftUI
TanreiTableOfContents
radio_button_unchecked
Jetpack Compose
TanreiTableOfContents
radio_button_unchecked
Flutter
TanreiTableOfContents
radio_button_unchecked
React Native
TableOfContents
radio_button_unchecked

Generated from docs/status/status.json, specs/table-of-contents.spec.json

The sections of this page: scroll, and the mark follows

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

<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>
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

Compact density, three levels, the third item current

The compact form: press the row

Anatomy

Part Description
1 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.
2 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.
3 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.
4 label An item's text: the heading it leads to, set in further for each level of nesting. It wraps and is never cut.
5 rail One hairline along the list's start edge, as long as the list. The track the marker sits on. Decorative.
6 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.
7 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.
8 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.
9 icon · optional The disclosure row's chevron (expand_more), turned half a turn while the list is shown. Decorative.
10 focus-ring The indicator shown for keyboard focus, inside the focused row's container.

Properties

Property Type Default Description
label string "" 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 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 list | compact "list" 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 regular | compact "regular" 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 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 "" 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 auto | manual "auto" 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.

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.

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.

Items

The kinds of item the component repeats. A fixture lists its items in order; each platform names each kind in its own idiom.

item · group — holds items

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.

Property 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.
Spec Web SwiftUI Jetpack Compose Flutter React Native
Item li TanreiTableOfContentsItem TanreiTableOfContentsItem TanreiTableOfContentsItem TableOfContentsItem
href · Property href id id id id
label · Property a label label label label
Notes An <li> of the product's list whose first child is its anchor, <a href="#installation">Installation</a>: href is the anchor's own attribute and the label its text. The item's own items are a <ul> after the anchor, inside the same <li>. The element writes aria-current="location" on the current item's anchor and removes it from the others; a product that sets tracking="manual" writes it itself or sets the element's current. TanreiTableOfContentsItem(id:, label:, items: []), a value. id is what the product's headings are registered under. TanreiTableOfContentsItem(id, label, items = emptyList()), a data class. TanreiTableOfContentsItem(id: …, label: …, items: const []), a value. An object of the items prop: { id, label, items? }.

States

Behavior

Requirements in EARS form. Every platform implementation is tested against these ids.

ID Requirement
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.

Platform name mapping

Parity does not mean identical APIs. Each platform uses its own idiom for the same contract.

Spec Web SwiftUI Jetpack Compose Flutter React Native
Component tanrei-table-of-contents TanreiTableOfContents TanreiTableOfContents TanreiTableOfContents TableOfContents
label · Property label label label label label
labelHidden · Property label-hidden labelHidden labelHidden labelHidden labelHidden
presentation · Property presentation presentation presentation presentation presentation
density · Property density density density density density
open · Property open isOpen open open open
current · Property current current current current current
tracking · Property tracking tracking tracking tracking tracking
currentChange · Event currentchange current onCurrentChange onCurrentChanged onCurrentChange
select · Event select onSelect onSelect onSelected onSelect
toggle · Event toggle isOpen onOpenChange onOpenChanged onOpenChange
Notes Form: <tanrei-table-of-contents label="On this page"> around the product's own list in the light DOM — <tanrei-table-of-contents label="On this page"><ul><li><a href="#install">Install</a><ul><li><a href="#npm">npm</a></li></ul></li></ul></tanrei-table-of-contents>. The anchors stay in the document (TOC-6): before the element is defined, and with scripting off, the page holds a plain nested list of links that work, which a crawler, a search index and a static site generator's own heading list all read and write as they are; nothing is copied, and a framework that renders the list keeps owning it. The element has a small shadow root that holds only what the product did not write — a <nav> named by label around the title, the disclosure row (a <button> with aria-expanded and aria-controls) and one <slot> for the list — so the landmark, the title and the compact form need no markup from the product; the title slot takes the product's heading. Everything drawn on the list comes from one stylesheet of the package, @tanrei/web/table-of-contents.css, whose selectors start at tanrei-table-of-contents and are wrapped in :where(): it styles the list before definition too, so the list presentation does not change place when the script loads. Before definition the compact presentation shows its whole list (there is no row yet); a layout that must not show that for a moment hides tanrei-table-of-contents[presentation="compact"]:not(:defined) itself. Level is the nesting of the <ul>s; the label's inset is padding-inline-start computed from a depth custom property the stylesheet sets per nested list. The marker is the anchor's ::before on the rail (a border-inline-start, so forced colors keep it, TOC-34); the heavier weight of the current label must not reflow the list (TOC-7) — reserve the heavier text's width with a hidden copy of the label (::after with content: attr(data-label), written by the element) or an equivalent the three engines agree on. Tracking (TOC-9 to TOC-16): the element resolves each anchor's fragment with document.getElementById, finds the nearest scrolling ancestor of the targets (else the viewport), and reads the line from each target's own computed scroll-margin-block-start plus the view's scroll-padding-block-start, which is where the browser itself rests a target — so prose's heading margin (PRS-23) and the product's fixed header are declared once, in CSS, and never passed to the element. It decides on scroll through one passive listener coalesced to a frame (or an IntersectionObserver per target; no frame is asked for at rest), on resize, and through a ResizeObserver on the scrolled content (TOC-16). TOC-12's hold begins on the anchor's click and on hashchange/popstate, and ends at the first wheel, touchmove, scrolling key or scroll-bar press after scrollend (a timer where scrollend is missing). The element never calls scrollIntoView on a target and never writes location.hash; TOC-17 uses scrollTo on the list's own scroll container only. TOC-28: the element blurs nothing and focuses nothing; it closes the list after the browser has taken the fragment navigation, which sets the sequential focus starting point at the target. select is dispatched from the anchor's click and is cancelable, cancelling the click with it. toggle carries detail.open; currentchange carries detail.href. A list that the product builds from a container's headings is the product's (or its site generator's) to build; the package may export a function that returns the nested items of a container's h2 to h4, and the element does not read headings itself. React: TableOfContents takes items ({ href, label, items? }[]) and renders the same element and list, or children. TanreiTableOfContents(label:, items:, current: Binding<String>, presentation:, isOpen: Binding<Bool>, tracking:, onSelect:). Targets are registered by a modifier on the product's heading views, .tanreiTableOfContentsTarget(id), inside a scroll view marked .tanreiTableOfContentsView(line:), which reports each target's place in the scroll view's coordinate space through a preference; the table of contents applies TOC-9 to TOC-16 to those places and writes current. onSelect is given the id and the product scrolls (a ScrollViewReader proxy or scrollPosition(id:)); TOC-12's hold ends at the next drag of the scroll view. An accessibility container with the navigation role where the platform has one, the items as links (isLink) with isSelected for current; the row is a button whose value is expanded or collapsed. TanreiTableOfContents(label, items, current, onCurrentChange, presentation, open, onOpenChange, tracking, onSelect, state). rememberTanreiTableOfContentsState() holds the targets' places: headings register with Modifier.tanreiTableOfContentsTarget(state, id) (onGloballyPositioned), a lazy list registers the indices of its headings, and the state applies TOC-9 to TOC-16 from the scroll state it is given. onSelect is given the id and the product scrolls (animateScrollToItem, bringIntoView). Items have the link semantics a link annotation has (Compose has no link role for a clickable node) and selected for current; the row has the expanded state with the expand and collapse actions. TanreiTableOfContents(label: …, items: …, current:, onCurrentChanged:, presentation:, open:, onOpenChanged:, tracking:, onSelected:, controller:). A TanreiTableOfContentsController is given the page's ScrollController; headings are wrapped in TanreiTableOfContentsTarget(id: …), whose render object's offset in the scrollable is read after layout, and the controller applies TOC-9 to TOC-16. onSelected is given the id and the product scrolls (Scrollable.ensureVisible). Semantics(link: true, selected: …) for items, expanded for the row; on the web target of Flutter the address is the product's router's. <TableOfContents label="…" items={…} current={…} onCurrentChange={…} />. useTableOfContents() returns what the page's ScrollView and its headings need — an onScroll handler, an onLayout per heading id, and the line — and applies TOC-9 to TOC-16 to them; onSelect is given the id and the product scrolls (scrollTo). accessibilityRole="link" with accessibilityState.selected for items, accessibilityState.expanded for the row. Follows the app's direction where an ancestor's is not known.

Tokens

Token Aliases Resolved (light)
--tanrei-comp-table-of-contents-compact-container-color sys.color.surface-container #f6f6f6
--tanrei-comp-table-of-contents-compact-container-shape sys.shape.corner.small 6px
--tanrei-comp-table-of-contents-compact-icon-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-table-of-contents-compact-icon-size sys.icon.small 20px
--tanrei-comp-table-of-contents-compact-list-padding-block sys.space.space100 8px
--tanrei-comp-table-of-contents-compact-list-padding-inline sys.space.space200 16px
--tanrei-comp-table-of-contents-compact-row-gap sys.space.space100 8px
--tanrei-comp-table-of-contents-compact-row-height — 48px
--tanrei-comp-table-of-contents-compact-row-padding-inline sys.space.space200 16px
--tanrei-comp-table-of-contents-compact-supporting-text-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-table-of-contents-container-gap sys.space.space100 8px
--tanrei-comp-table-of-contents-current-container-color — #000000 · 0%
--tanrei-comp-table-of-contents-current-focused-container-color — #1b1b1b · 10%
--tanrei-comp-table-of-contents-current-focused-label-color — #1b1b1b
--tanrei-comp-table-of-contents-current-focused-state-layer-color — #1b1b1b
--tanrei-comp-table-of-contents-current-hovered-container-color — #1b1b1b · 8%
--tanrei-comp-table-of-contents-current-hovered-label-color — #1b1b1b
--tanrei-comp-table-of-contents-current-hovered-state-layer-color — #1b1b1b
--tanrei-comp-table-of-contents-current-label-color sys.color.on-surface #1b1b1b
--tanrei-comp-table-of-contents-current-pressed-container-color — #1b1b1b · 10%
--tanrei-comp-table-of-contents-current-pressed-label-color — #1b1b1b
--tanrei-comp-table-of-contents-current-pressed-state-layer-color — #1b1b1b
--tanrei-comp-table-of-contents-current-state-layer-color comp.table-of-contents.current.label.color #1b1b1b
--tanrei-comp-table-of-contents-disclosure-container-color — #000000 · 0%
--tanrei-comp-table-of-contents-disclosure-focused-container-color — #1b1b1b · 10%
--tanrei-comp-table-of-contents-disclosure-focused-label-color — #1b1b1b
--tanrei-comp-table-of-contents-disclosure-focused-state-layer-color — #1b1b1b
--tanrei-comp-table-of-contents-disclosure-hovered-container-color — #1b1b1b · 8%
--tanrei-comp-table-of-contents-disclosure-hovered-label-color — #1b1b1b
--tanrei-comp-table-of-contents-disclosure-hovered-state-layer-color — #1b1b1b
--tanrei-comp-table-of-contents-disclosure-label-color sys.color.on-surface #1b1b1b
--tanrei-comp-table-of-contents-disclosure-pressed-container-color — #1b1b1b · 10%
--tanrei-comp-table-of-contents-disclosure-pressed-label-color — #1b1b1b
--tanrei-comp-table-of-contents-disclosure-pressed-state-layer-color — #1b1b1b
--tanrei-comp-table-of-contents-disclosure-state-layer-color comp.table-of-contents.disclosure.label.color #1b1b1b
--tanrei-comp-table-of-contents-focused-opacity — 1
--tanrei-comp-table-of-contents-hovered-opacity — 1
--tanrei-comp-table-of-contents-item-container-color — #000000 · 0%
--tanrei-comp-table-of-contents-item-focused-container-color — #4b4b4b · 10%
--tanrei-comp-table-of-contents-item-focused-label-color — #4b4b4b
--tanrei-comp-table-of-contents-item-focused-state-layer-color — #4b4b4b
--tanrei-comp-table-of-contents-item-hovered-container-color — #4b4b4b · 8%
--tanrei-comp-table-of-contents-item-hovered-label-color — #4b4b4b
--tanrei-comp-table-of-contents-item-hovered-state-layer-color — #4b4b4b
--tanrei-comp-table-of-contents-item-label-color sys.color.on-surface-variant #4b4b4b
--tanrei-comp-table-of-contents-item-pressed-container-color — #4b4b4b · 10%
--tanrei-comp-table-of-contents-item-pressed-label-color — #4b4b4b
--tanrei-comp-table-of-contents-item-pressed-state-layer-color — #4b4b4b
--tanrei-comp-table-of-contents-item-state-layer-color comp.table-of-contents.item.label.color #4b4b4b
--tanrei-comp-table-of-contents-marker-color sys.color.primary #1b1b1b
--tanrei-comp-table-of-contents-marker-width sys.border.thick 2px
--tanrei-comp-table-of-contents-motion-disclosure-duration sys.motion.role.disclosure 400ms
--tanrei-comp-table-of-contents-motion-disclosure-easing sys.motion.easing.spatial cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-table-of-contents-motion-feedback-duration sys.motion.role.feedback 450ms
--tanrei-comp-table-of-contents-motion-state-duration sys.motion.role.state.large 250ms
--tanrei-comp-table-of-contents-motion-state-easing sys.motion.easing.state cubic-bezier(0.2, 0, 0, 1)
--tanrei-comp-table-of-contents-pressed-opacity — 1
--tanrei-comp-table-of-contents-rail-color sys.color.outline-variant #000000 · 8%
--tanrei-comp-table-of-contents-rail-width sys.border.hairline 1px
--tanrei-comp-table-of-contents-row-height-compact — 32px
--tanrei-comp-table-of-contents-row-height-regular — 44px
--tanrei-comp-table-of-contents-row-indent sys.space.space150 12px
--tanrei-comp-table-of-contents-row-padding-block-compact — 6px
--tanrei-comp-table-of-contents-row-padding-block-regular — 12px
--tanrei-comp-table-of-contents-row-padding-inline sys.space.space150 12px
--tanrei-comp-table-of-contents-row-shape sys.shape.corner.extra-small 3px
--tanrei-comp-table-of-contents-title-color sys.color.on-surface #1b1b1b
--tanrei-comp-table-of-contents-compact-supporting-text-typescale sys.typescale.body-small 400 13px/1.5385
--tanrei-comp-table-of-contents-label-typescale sys.typescale.body-small 400 13px/1.5385
--tanrei-comp-table-of-contents-title-typescale sys.typescale.label 500 14px/1.4286

Fixtures

Canonical renderings. Every platform snapshots each fixture under the same id so the results can be compared.

Generated from specs/table-of-contents.spec.json, tokens/comp/table-of-contents.tokens.json, design/tokens/

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:

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).

  • 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

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
The hit area meets the minimum target size even when the visual size is smaller. minimum 32px · pointer 44px · spacing 8px · touch 48px

Keyboard

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.

Notes

  • 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.

Generated from specs/table-of-contents.spec.json, tokens/sys/measure.tokens.json

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.