Table of Contents

The sections of a long page, the one being read marked, with reading progress.

zweiundeins <sb-toc> toc table of contents navigation scroll spy progress article Since 2026-10-06 MIT licence 4.3 kB0Open in playground Edit on GitHub

Playground

The sections of a long page: a blog post, documentation, a report. The entry you are reading is marked as you scroll, a link jumps to its section, and a bar shows how far through you are. Put it in a sticky sidebar. On narrow screens, compact folds it into a bar with the current section, and the list opens from there.

It lists the headings of content (by default the closest <article>, else <main>), levels deep, and keeps watching them, so headings that arrive later join the list. On a page with neither, it reads the whole <body> once and doesn't watch it; there, point content at an element inside the body. Headings without an id get one from their text, but only once the module runs: give them ids on the server, so a shared link to a section lands there on load. Or render the list on the server: put an <ol> (or <ul>) of #id links inside the element and it uses those. That list is also what readers and crawlers see without JavaScript; nested lists become indented levels.

Examples

Beside an article

The playground above lists the sections of this page. A sidebar usually sits in a grid next to the text, sticky, and no taller than the viewport: the list scrolls on its own, keeping the current entry in view.

T−10 minutes. Tanks pressurised, guidance aligned, the crew strapped in. Weather is go.

Launch

Main engines ignite at T−6 seconds. Hold-down arms release, and the stack clears the tower in eight seconds, rolling onto its heading.

Max-Q at one minute ten: the hardest push of the ascent. Engines throttle down, then back up.

Staging

The first stage cuts off at two minutes forty and falls away. The second stage lights cleanly; the escape tower is jettisoned.

Orbit

Insertion at eleven minutes, 190 kilometres up. Two orbits of checks before the burn that leaves Earth behind.

Translunar injection

A six-minute burn over the Pacific. The planet shrinks in the window to something you can cover with a thumb.

Landing

Powered descent from fifteen kilometres. Program alarms, a boulder field, the last seconds of fuel, and then contact light.

Return

Re-entry at eleven kilometres per second behind a heat shield, three parachutes, and a splash in the Pacific.

<div style="display: grid; grid-template-columns: minmax(0, 11rem) minmax(0, 1fr); gap: 32px; align-items: start; inline-size: 100%">
  <sb-toc content="#mission-log" levels="h3 h4" start-label="Countdown" progress
    style="position: sticky; inset-block-start: 5rem; max-block-size: calc(100vh - 7rem)"></sb-toc>
  <article id="mission-log" style="display: grid; gap: 12px; max-inline-size: 34rem">
    <p>T−10 minutes. Tanks pressurised, guidance aligned, the crew strapped in. Weather is go.</p>
    <h3>Launch</h3>
    <p>Main engines ignite at T−6 seconds. Hold-down arms release, and the stack clears the tower in eight seconds, rolling onto its heading.</p>
    <p>Max-Q at one minute ten: the hardest push of the ascent. Engines throttle down, then back up.</p>
    <h4>Staging</h4>
    <p>The first stage cuts off at two minutes forty and falls away. The second stage lights cleanly; the escape tower is jettisoned.</p>
    <h3>Orbit</h3>
    <p>Insertion at eleven minutes, 190 kilometres up. Two orbits of checks before the burn that leaves Earth behind.</p>
    <h4>Translunar injection</h4>
    <p>A six-minute burn over the Pacific. The planet shrinks in the window to something you can cover with a thumb.</p>
    <h3>Landing</h3>
    <p>Powered descent from fifteen kilometres. Program alarms, a boulder field, the last seconds of fuel, and then contact light.</p>
    <h3>Return</h3>
    <p>Re-entry at eleven kilometres per second behind a heat shield, three parachutes, and a splash in the Pacific.</p>
  </article>
</div>

start-label adds a first entry that leads back to the start of the content; it is the current one while the start of the content is in view, however short the intro. The counter shows where you are (3/7), and progress adds the reading progress under the heading.

A list from the server

The links are the server's: their text, their order, their nesting. The component reads them, marks the current one and keeps reading them when a morph changes the list.

<sb-toc label="On this page" style="inline-size: 14rem">
  <ol>
    <li><a href="#beside-an-article">Beside an article</a></li>
    <li><a href="#a-list-from-the-server">A list from the server</a></li>
    <li><a href="#compact">Compact</a></li>
    <li><a href="#styling">Styling</a>
      <ol><li><a href="#accessibility">Accessibility</a></li></ol>
    </li>
  </ol>
</sb-toc>

Compact

compact takes a media query. While it matches, the list folds into a bar: the label, the current section, the counter and the reading progress along its edge. The bar opens the list as a popover; picking a section, Escape or a click outside closes it. compact="all" keeps it folded everywhere, as here.

<sb-toc compact="all" content="#mission-log" levels="h3" start-label="Countdown" style="inline-size: min(100%, 22rem)"></sb-toc>

On a phone, make the bar sticky at the top of the article: the reader always sees where they are and can jump anywhere.

Following the reader

sb-section fires when the reader scrolls into another section, with its id (empty before the first heading). Here it drives a line of text:

<div data-signals="{_section: ''}" style="display: grid; gap: 8px; inline-size: min(100%, 22rem)">
  <sb-toc compact="all" content="#mission-log" levels="h3" data-on:sb-section="$_section = evt.detail.id"></sb-toc>
  <code data-text="$_section ? 'Reading #' + $_section : 'Scroll the mission log…'"></code>
</div>

Styling

Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.

  • Size and place: it is as wide as its container. For a sticky sidebar, set position: sticky, an inset-block-start and a max-block-size on the element; the list scrolls inside it.
  • Where "current" starts: a section is current once its heading passes 30% of the way down the viewport, below the page's scroll-padding-top. Set that on html for a fixed header; links land below it too. Content in its own scroll box (overflow: auto) is followed in that box: its top, its scroll padding, its end.
  • 8-bit details: the label and the counter use --sb-font-display, the marker on the rail is square while --sb-notch is 1 (round at 0), the progress bar is made of blocks (a plain bar at 0), and the compact bar and its list have a stepped frame --sb-frame-step thick. data-sb-style="smooth" turns them all off.
  • Colours: entries are --sb-text-2, the current one --sb-text-1 with a --sb-brand marker, on a --sb-border rail; hover is --sb-surface-hover. The label is --sb-text-muted. The compact bar and list are --sb-surface-card and cast --sb-shadow-overlay; the focus ring is --sb-focus-ring.
  • Parts: nav, head, label, counter, progress, list, link (every entry), bar and panel (the compact list). Your page's ::part() rules win over the component's own, without !important.
<style>
  .my-toc { --sb-brand: #FFD166; --sb-notch: 0; --sb-font-display: Georgia, serif; inline-size: 14rem; }
  .my-toc::part(label) { text-transform: none; letter-spacing: 0; font-size: 1rem; }
  .my-toc::part(link) { font-size: 0.9375rem; }
</style>
<sb-toc class="my-toc" label="In this log" content="#mission-log" levels="h3"></sb-toc>

Accessibility

It is a <nav> landmark named by label. The current entry has aria-current="location", so screen readers announce it on the link. Links are ordinary anchors: they move the focus to the section as any in-page link does, add a history entry, and follow the page's scroll-behavior (so prefers-reduced-motion can turn smooth scrolling off). The start entry scrolls to the content without adding a # to the address.

The compact bar is a button that opens the list as a popover: the browser exposes it as expanded or collapsed, Escape closes the list and returns the focus to the bar. The counter and the progress bar are hidden from screen readers; the current section is in the bar's text.

Installation

Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.

<script type="importmap">
  { "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>

<sb-toc content="article" levels="h2 h3" start-label="Intro" compact="(max-width: 64rem)"></sb-toc>

No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.

Load just this component, pinned to this version: the minified module with its integrity hash, so the browser refuses it if a single byte changes.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/toc@d50fa35d8d62/toc.min.js": "sha384-ZlvMg7+EYSJ0zq6A9C5az7iQdskFq7+QWve5dNzeKACC4oTuMAi/SpeW5rOUm7UA"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/toc@d50fa35d8d62/toc.min.js" integrity="sha384-ZlvMg7+EYSJ0zq6A9C5az7iQdskFq7+QWve5dNzeKACC4oTuMAi/SpeW5rOUm7UA"></script>

<sb-toc content="article" levels="h2 h3" start-label="Intro" compact="(max-width: 64rem)"></sb-toc>

In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/toc@d50fa35d8d62/toc.min.js": "sha384-ZlvMg7+EYSJ0zq6A9C5az7iQdskFq7+QWve5dNzeKACC4oTuMAi/SpeW5rOUm7UA"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@c165875fb681/autoloader.js" integrity="sha384-GSnXFTGY2G0KaLoU4ay3eO2eDT2deAiOwFYw6q+cyzLfqQzEKw47znHC9xsLt/VR"></script>

<sb-toc content="article" levels="h2 h3" start-label="Intro" compact="(max-width: 64rem)"></sb-toc>

Components import only datastar and their own files, so copying the folder is enough: save these files, keeping each component's folder, and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).

<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/toc/toc.min.js"></script>

<sb-toc content="article" levels="h2 h3" start-label="Intro" compact="(max-width: 64rem)"></sb-toc>

Size

Each file compressed on its own, the way it is served (gzip -9, brotli -11). The autoloader loads the minified files (esbuild), so that last column is what a page downloads; the readable source is always there too. Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotliminified
toc.js 20.7 kB7.4 kB6.5 kB4.3 kB

API reference

Props

AttributeTypeDefaultDescription
contentstring""CSS selector of the element whose headings it lists. Default: the closest <article>, else <main>, else the page. A list of links inside the element wins over it.
levelsstring"h2"The headings to list, e.g. "h2 h3". Deeper levels are indented.
labelstring"Contents"Heading of the list, and the accessible name of the navigation.
start-labelstring""Adds a first entry that leads back to the start of the content, e.g. "Intro". It is the current one while the start of the content is in view.
compactstring""A media query. While it matches, the list folds into a bar with the current section and the reading progress, and opens from it, e.g. "(max-width: 64rem)".
progressbooleanfalseShow the reading progress under the heading as well (the compact bar always shows it).

Events

NameDescription
sb-sectionWhen the reader scrolls into another section. detail: { id } (empty before the first heading).