Navigation

v0.0.4
Navigation

Table of contents

The headings of a page as links, with the section in view marked by CSS.

On this page

A table of contents lists the headings of a page as a quiet column of links on a hairline rail. The link of the section in view is in ink, with a mark on the rail that glides from one link to the next. Where the browser supports scroll-target-group, the page marks it by itself: the list watches the scroll, and the link whose target is in view matches :target-current. No observer, no script.

Table of contents

<div class="grid grid-cols(2) gap-lg">  <div class="h(240) overflow-y-auto scroll-area text-sm leading-md fg-text-body">    <h3 class="m(0) fg-text-strong" id="toc-install">Install</h3>    <p>Add the package and import it after the preset. The stylesheet has no build step of its own.</p>    <p>It ships as plain CSS files, one per component.</p>    <h3 class="fg-text-strong" id="toc-theme">Theme</h3>    <p>Moonstone and obsidian share one set of tokens. Override a token and every component follows.</p>    <p>The brand color is one token.</p>    <h3 class="fg-text-strong" id="toc-corners">Corners</h3>    <p>Corners turn with continuous curvature where the browser can draw them, and nest by their inset.</p>    <p>Elsewhere they are circular.</p>    <h3 class="fg-text-strong" id="toc-motion">Motion</h3>    <p>One easing family. Interaction is quick, explanation calm, and reduced motion keeps the fade.</p>    <p class="mb(160)">That is the whole system.</p>  </div>  <nav class="toc self-start" aria-label="On this page">    <p class="toc-title">On this page</p>    <ul class="toc-list">      <li>        <a class="toc-link" href="#toc-install">Install</a>      </li>      <li>        <a class="toc-link" href="#toc-theme">Theme</a>      </li>      <li>        <a class="toc-link" href="#toc-corners">Corners</a>      </li>      <li>        <a class="toc-link" href="#toc-motion">Motion</a>      </li>    </ul>  </nav></div>
Scroll the text: the mark follows the section in view. In a browser without scroll-target-group, nothing is marked until a script sets aria-current.

#Levels

A toc-list inside a list item is a deeper level. It shares the rail and indents its links by one step. Two levels are enough for a page; a third is a sign the page wants splitting.

Levels

<nav class="toc max-w(220)" aria-label="On this page">  <ul class="toc-list">    <li>      <a class="toc-link" href="#">Overview</a>    </li>    <li>      <a class="toc-link" href="#" aria-current="true">Theme</a>      <ul class="toc-list">        <li>          <a class="toc-link" href="#">Surfaces</a>        </li>        <li>          <a class="toc-link" href="#">Lines and shadows</a>        </li>        <li>          <a class="toc-link" href="#">The brand color</a>        </li>      </ul>    </li>    <li>      <a class="toc-link" href="#">Accessibility</a>    </li>  </ul></nav>

#Marking the section yourself

aria-current="true" on a link draws the same state as :target-current. Set it from the server for a static list, or from an IntersectionObserver where the scroll-target group is not supported. When both are present they agree, since both follow the section in view.

#Accessibility

  • The list is a <nav> with a label such as "On this page", so it can be found among the page's landmarks and told apart from the main navigation.
  • Links go to headings by id. Give the scroller scroll-padding-top when a navbar is stuck above it, so a heading does not land under the bar.
  • The current link changes ink, and the mark is one and a half pixels of ink beside it; neither depends on hue. With reduced motion the mark moves without gliding.

#Classes

ClassDescription
tocThe container, on a nav with a label such as "On this page"; a scroll-target group
toc-titleA caption over the list
toc-listA list of links on a hairline rail. A toc-list inside a list item is a deeper level: it shares the rail and indents its links by one step
toc-linkA link to a heading by its id; the one of the section in view is in ink, with a mark on the rail

#Custom properties

PropertyDefaultDescription
--toc-indent.875remThe distance from the rail to the words, and one level's indent

#Source

@master/ui/components/toc.css
/* * Table of contents: the headings of a page as a quiet column of links on a hairline rail. * The link of the section in view is in ink, with a mark on the rail. Where the browser supports * scroll-target-group the page marks it by itself: the list watches the scroll and the link whose target is * in view matches :target-current. A script or a server can set aria-current instead; both draw the same. * With anchor positioning the mark is one piece that glides along the rail. */@layer components {  .toc {    --toc-indent: .875rem;    position: relative;    display: grid;    min-width: 0;    font-size: .8125rem;    line-height: 1.4;    scroll-target-group: auto;  }  /* A caption over the list. */  .toc-title {    margin: 0 0 .625rem;    color: var(--color-text-strong);    font-size: .75rem;    font-weight: var(--font-weight-control);    line-height: 1.4;  }  /* The list draws the rail; a list inside a list is a deeper level and shares the same rail. */  .toc-list {    display: grid;    min-width: 0;    margin: 0;    padding: 0;    box-shadow: inset 1px 0 0 var(--color-line-divider);    list-style: none;    &:dir(rtl) {      box-shadow: inset -1px 0 0 var(--color-line-divider);    }    .toc-list {      box-shadow: none;    }    .toc-list .toc-link {      padding-inline-start: calc(var(--toc-indent) * 2);    }  }  .toc-link {    position: relative;    display: block;    padding: .3125rem .5rem .3125rem var(--toc-indent);    color: var(--color-text-muted);    text-decoration: none;    text-wrap: pretty;    transition: color var(--duration-quick) var(--easing-glide);    &:dir(rtl) {      padding: .3125rem var(--toc-indent) .3125rem .5rem;    }    @media (hover: hover) {      &:hover {        color: var(--color-text-strong);      }    }    /* Pressed: the strong ink, as under the pointer, so a tap is answered too. */    &:active {      color: var(--color-text-strong);    }    &:is(:target-current, [aria-current]) {      color: var(--color-text-strong);    }    /* The mark: a short piece of ink on the rail, with round ends, a few pixels in from the row's edges. */    &:is(:target-current, [aria-current])::before {      content: '';      position: absolute;      inset-block: .3125rem;      inset-inline-start: 0;      width: 1.5px;      border-radius: 999px;      background: var(--color-text-strong);    }    &:focus-visible {      @apply --focus-ring;      outline-offset: -2px;      border-radius: calc(var(--radius-chip) * var(--corner-scale));      corner-shape: var(--corner-shape);    }    @media (forced-colors: active) {      &:is(:target-current, [aria-current]) {        color: Highlight;      }      &:is(:target-current, [aria-current])::before {        background: Highlight;      }    }  }  @supports (anchor-scope: --toc-mark) {    .toc {      anchor-scope: --toc-mark;      &:has(.toc-link:is(:target-current, [aria-current]))::before {        content: '';        position: absolute;        position-anchor: --toc-mark;        top: calc(anchor(top) + .3125rem);        bottom: calc(anchor(bottom) + .3125rem);        inset-inline-start: 0;        width: 1.5px;        border-radius: 999px;        background: var(--color-text-strong);        pointer-events: none;        transition-property: top, bottom;        transition-duration: var(--duration-base);        transition-timing-function: var(--easing-glide);      }      @media (prefers-reduced-motion: reduce) {        &::before {          transition: none;        }      }      @media (forced-colors: active) {        &::before {          background: Highlight;        }      }    }    .toc-link:is(:target-current, [aria-current]) {      anchor-name: --toc-mark;    }    .toc-link:is(:target-current, [aria-current])::before {      content: none;    }  }}