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, aninset-block-startand amax-block-sizeon 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 onhtmlfor 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-notchis 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-stepthick.data-sb-style="smooth"turns them all off. - Colours: entries are
--sb-text-2, the current one--sb-text-1with a--sb-brandmarker, on a--sb-borderrail; hover is--sb-surface-hover. The label is--sb-text-muted. The compact bar and list are--sb-surface-cardand cast--sb-shadow-overlay; the focus ring is--sb-focus-ring. - Parts:
nav,head,label,counter,progress,list,link(every entry),barandpanel(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.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
toc.js | 20.7 kB | 7.4 kB | 6.5 kB | 4.3 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
content | string | "" | 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. |
levels | string | "h2" | The headings to list, e.g. "h2 h3". Deeper levels are indented. |
label | string | "Contents" | Heading of the list, and the accessible name of the navigation. |
start-label | string | "" | 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. |
compact | string | "" | 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)". |
progress | boolean | false | Show the reading progress under the heading as well (the compact bar always shows it). |
Events
| Name | Description |
|---|---|
sb-section | When the reader scrolls into another section. detail: { id } (empty before the first heading). |