Contents

An "On this page" list of links to the sections of a page. Contents.Root is a nav landmark holding a list, each Contents.Item holds a Contents.Link to a section's heading by its fragment, and a Contents.List nests the sections under one heading. While the page scrolls, the link to the section being read takes aria-current="location".

On this page
<Contents.Root label="On this page">
  <Contents.Item>
    <Contents.Link href="#install">Install</Contents.Link>
  </Contents.Item>
  <Contents.Item>
    <Contents.Link href="#tokens">Tokens</Contents.Link>
    <Contents.List>
      <Contents.Item>
        <Contents.Link href="#colors">Colors</Contents.Link>
      </Contents.Item>
    </Contents.List>
  </Contents.Item>
</Contents.Root>

Install

Add the package and its stylesheet, then wrap the app in its providers.

Tokens

Build your token package on top of the base set, and keep your overrides in your own layer.

Colors

Point the appearance tokens at your palette. Every component follows.

Spacing

Density tokens set the gaps, so a compact screen needs no new CSS.

Ship

Publish the package with a changeset.

You give the links, as you do for Nav. Todae doesn't read the page's headings, so the list says what you choose and your server can render it. Give each heading an id that its link's href names; a link to another page is not marked current.

The section being read

A section is current once its heading has scrolled past its line: the place a fragment link scrolls the heading to, which is the scroll container's scroll-padding-block-start plus the heading's own scroll-margin-block-start. Set either one to clear a sticky header, and the list counts a section as read from the same line a click on its link scrolls it to. The current section is the last one whose heading has passed its line; above the first heading, no link is current.

Contents.Root follows the headings with IntersectionObserver, in the nearest container around the first heading that scrolls, or the page. It measures the lines again when the scroller, the first heading's parent or the window resizes, and follows a heading that mounts after its link, is replaced or is renamed.

A short last section may never reach its line, because the page can't scroll that far. So a click on a link, or a hash that names a section the page shows, when the list mounts or the hash changes, picks that section. A hash change that names the picked section keeps the pick, so a click holds while a smooth scroll is still on its way. The pick stays current until the person scrolls with the wheel, a touch or a scroll key that no field takes and no widget stops or cancels (Space on a button presses it), until it has been in view and leaves it, or until the hash changes to name something else.

Chromium paints the current link with native CSS scrollspy as a progressive enhancement. When that link is not the script's link, the script's link keeps the marker, so one link looks current. The script still sets aria-current="location". That attribute stays the source of truth until Firefox and Safari support the feature.

Look

The links sit beside one rule, from contents.rule.color and contents.rule.width, and the current link draws a marker on it, from contents.marker.color and contents.marker.width, with the contents.link.fg.current color and contents.link.font.current-weight. A nested list indents its links' text by contents.indent per level, so every marker lands on the same rule. In forced colors the marker is Highlight.

Contents.Link takes render the way Link does, for a router's link. The example above uses it to scroll only its own box.

On a narrow screen

Contents has no folding of its own. Where a column beside the page doesn't fit, put the list in a Disclosure under the page's title, as this site does, or leave it out:

<Disclosure.Root>
  <Disclosure.Summary>On this page</Disclosure.Summary>
  <Contents.Root aria-label="On this page">…</Contents.Root>
</Disclosure.Root>

Accessible composition

Todae handles: A nav landmark, named by label, aria-label, aria-labelledby or the contents string from StringsProvider. Each list is a ul role="list". aria-current="location" on the link to the section being read, so a screen reader says which section it is, not only a marker drawn in color. Focus rings drawn inside the link, so a scroll container that clips its sides keeps them whole.

You supply: A heading with an id for each link, and link text that matches the heading. A sticky header's height as scroll-padding-block-start or scroll-margin-block-start, so a section isn't hidden under it.

Watch for: Two Contents on one page with the same name, such as a column and a folded copy both shown at once: hide the one not in use with display: none. Links to elements that aren't headings, which a screen reader user can't reach with heading navigation.

When to use

When: A long page with several sections, such as a guide, a docs page or a settings page, where readers jump between sections and want to know where they are.

When not: Navigation between pages → Nav. The trail above the current page → Breadcrumbs. Panels that show one at a time → Tabs. A page with two or three short sections, which needs no list.

Props

Contents.Root props
NameTypeDescription
labelReact.ReactNode

Text shown above the list, which also names the landmark.

aria-labelstring

Landmark name when there is no label or aria-labelledby. Default is the contents string from StringsProvider.

childrenReact.ReactNode

Contents.Item elements.

Contents.List props
NameTypeDescription
childrenReact.ReactNode

Contents.Item elements.

Contents.Item props
NameTypeDescription
childrenReact.ReactNode

A Contents.Link, or a Contents.Link followed by the Contents.List of its sub-sections.

Contents.Link props
NameTypeDescription
hrefstring

The fragment of the section, such as #props. A link to another page is not marked current.

render(props: React.ComponentPropsWithRef<"a">) => React.ReactElement

Renders something other than a plain a, such as a router link. It receives every prop, the class name included, and must pass them to an a.

aria-current"page"| "step"| "location"| "date"| "time"| "true"| "false"

Set by Contents.Root to location on the link to the section being read. A value you pass wins.

childrenReact.ReactNode

The link text, usually the heading of the section.