Navigation
Table of Contents
The “on this page” rail, with a marker that slides onto the section you are reading.
import { Toc } from 'omaris' Examples
Basic
The rail follows whatever scrolls — here a panel, on this page the window. Scroll the column on the left and the marker slides onto the section whose heading has gone past the top.
<script lang="ts"> import { Toc } from 'omaris'; const sections = [ { id: 'demo-install', text: 'Installation', level: 2 }, { id: 'demo-cli', text: 'With the CLI', level: 3 }, { id: 'demo-manual', text: 'By hand', level: 3 }, { id: 'demo-theming', text: 'Theming', level: 2 }, { id: 'demo-tokens', text: 'Design tokens', level: 3 }, { id: 'demo-recipes', text: 'Recipes', level: 2 } ];</script><!-- One column on a phone: a 12rem rail beside the article leaves the article about 60px, which is one word a line.--><div class="grid w-full grid-cols-1 gap-8 sm:grid-cols-[minmax(0,1fr)_12rem]"> <!-- svelte-ignore a11y_no_noninteractive_tabindex --> <div class="scrollbar-subtle h-80 overflow-y-auto rounded-shape-md border border-border" tabindex="0" role="group" aria-label="Article" > <div class="flex flex-col gap-6 p-5"> {#each sections as section (section.id)} <section class="flex flex-col gap-2"> <h2 id={section.id} class="scroll-mt-5 text-title-sm text-foreground">{section.text}</h2> <p class="text-body-md text-muted-foreground"> Enough copy to scroll past, so the rail beside it has something to follow. The marker measures the link it lands on, so it fits a wrapped label as readily as a short one. </p> </section> {/each} </div> </div> <!-- `offset` is measured from the top of whatever scrolls — here the panel. --> <Toc items={sections} offset={24} /></div> Levels
Indentation is measured from the shallowest heading in the list, so a page that never uses h2 reads flush instead of indented once.
h2 · Overview
h3 · Props
h4 · The class prop
h4 · The classes prop
h2 · Parts
<script lang="ts"> import { Toc } from 'omaris'; const items = [ { id: 'lv-overview', text: 'Overview', level: 2 }, { id: 'lv-props', text: 'Props', level: 3 }, { id: 'lv-props-class', text: 'The class prop', level: 4 }, { id: 'lv-props-classes', text: 'The classes prop', level: 4 }, { id: 'lv-parts', text: 'Parts', level: 2 } ]; /** The page beside the rail, indented the same way the rail is. */ const indent = { 2: '', 3: 'ps-5', 4: 'ps-10' } as const;</script><!-- One column on a phone; the rail is 12rem and would leave nothing beside it. --><div class="grid w-full grid-cols-1 items-start gap-8 sm:grid-cols-[minmax(0,1fr)_12rem]"> <div class="flex flex-col gap-3 rounded-shape-md border border-border p-5"> {#each items as item (item.id)} <p id={item.id} class="text-body-md text-foreground {indent[item.level as 2 | 3 | 4]}"> h{item.level} · {item.text} </p> {/each} </div> <Toc {items} spy={false} active="lv-props-classes" label="Mixed levels" /></div> Controlled
spy={false} hands the highlight over: bind:active is then the only thing that moves the marker.
active: ct-usage
<script lang="ts"> import { Toc, Button } from 'omaris'; const items = [ { id: 'ct-overview', text: 'Overview', level: 2 }, { id: 'ct-usage', text: 'Usage', level: 2 }, { id: 'ct-api', text: 'API', level: 2 } ]; let active = $state('ct-usage');</script><!-- One column on a phone; the rail is 11rem and would leave nothing beside it. --><div class="grid w-full grid-cols-1 items-start gap-8 sm:grid-cols-[minmax(0,1fr)_11rem]"> <div class="flex flex-col items-start gap-4"> <div class="flex flex-wrap gap-2"> {#each items as item (item.id)} <Button id={item.id} size="sm" variant={active === item.id ? 'filled' : 'outlined'} onclick={() => (active = item.id)} > {item.text} </Button> {/each} </div> <p class="text-body-sm text-muted-foreground">active: {active}</p> </div> <Toc {items} spy={false} bind:active /></div> Overridden
Every part is reachable: classes reaches the rail, the marker and the links, so the same component can be a dashed outline with a thick marker and no heading at all.
Summary
Detail
Notes
<script lang="ts"> import { Toc } from 'omaris'; const items = [ { id: 'ov-summary', text: 'Summary', level: 2 }, { id: 'ov-detail', text: 'Detail', level: 3 }, { id: 'ov-notes', text: 'Notes', level: 3 } ];</script><div class="flex items-start gap-10"> <Toc {items} spy={false} active="ov-detail" showLabel={false} size="sm" class="w-44 max-w-full" classes={{ rail: 'border-dashed border-primary/40', marker: 'w-1 rounded-none', link: 'py-2 tracking-wide uppercase', item: 'border-b border-border/60 last:border-b-0' }} /> <div class="flex flex-col gap-2"> {#each items as item (item.id)} <p id={item.id} class="text-body-sm text-muted-foreground">{item.text}</p> {/each} </div></div> When to use it
Use it for
- The "on this page" rail beside a long document, a report, or a settings page with a dozen sections.
- Content that scrolls inside an App Shell column, a panel or a dialog. It follows whatever scrolls, not only the window.
- A page that knows its own position.
spy={false}andbind:activemove the marker from your own stepper or form.
Not for
- Moving between pages → Navigation Drawer.
- Sections that swap rather than scroll → Tabs.
- Where this page sits among others → Breadcrumb.
- A page with two headings → drop the rail. The marker needs enough sections to move between.
Do
- Set
offsetto the height of whatever sticks to the top, like astickyTop App Bar, plus a little. Otherwise the marker changes section early. - Give every heading a stable
id, and passlevelso nesting indents. Indentation is relative to the shallowest level in the list. - Hide it below a wide breakpoint (
hidden xl:block). It needs a column of its own, and a phone has none. - Use
size="sm"when it shares a narrow column with something else.
Don't
- List every
h4. Two levels is enough; deeper is noise. - Drop
labelwhen hiding the heading withshowLabel={false}. The accessible name comes from it. - Leave headings without ids. The links are anchors, and nothing scrolls to a heading it cannot find.
Quick reference
size smmd(default)
indent 0(default)123
Distance from the shallowest heading on the page, in levels.
API
Toc
Toc — the "on this page" rail.
A list of the page's headings against a hairline, with a marker that slides and resizes onto the section you are reading. The marker is one element rather than a border per link, which is what makes the movement readable.
Tracking works wherever the page scrolls. The listener is registered on the document in the capture phase, so a column that scrolls inside an AppShell — or a dialog, or a panel — is followed as readily as the window. Pass spy={false} and bind:active to drive it yourself.
import { Toc } from 'omaris' Parts
Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.
root- No description in the source yet.
title- No description in the source yet.
rail- Holds the hairline the marker rides on, and the marker itself.
marker- No description in the source yet.
list- No description in the source yet.
item- No description in the source yet.
link- No description in the source yet.
Props
items Defaults to []
TocItem[] The headings, in the order they appear on the page.
label Defaults to 'On this page'
string Heading above the list, and the nav's accessible name.
showLabel Defaults to true
boolean Draw the heading. The accessible name survives either way.
active bindableDefaults to undefined
string The id of the highlighted heading. Bindable.
spy Defaults to true
boolean Follow the scroll position. Off leaves active to the consumer.
offset Defaults to 96
number How far below the top of whatever scrolls — the viewport, or the panel the headings are in — a heading counts as read, in pixels: the height of whatever sticks to the top, plus a little.
size Defaults to 'md'
TocSize smmd
marker Defaults to true
boolean Draw the sliding marker. Off leaves the hairline and the colour.
onnavigate (id: string) => void Runs when a link is clicked, after active has moved.
class string classes TocClasses Per-part Tailwind overrides. class still covers the root.