Skip to content
omaris

Navigation

Table of Contents

The “on this page” rail, with a marker that slides onto the section you are reading.

import { Toc } from 'omaris'
Learn

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.

Installation

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.

With the CLI

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.

By hand

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.

Theming

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.

Design tokens

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.

Recipes

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.

<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} and bind:active move 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 offset to the height of whatever sticks to the top, like a sticky Top App Bar, plus a little. Otherwise the marker changes section early.
  • Give every heading a stable id, and pass level so 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 label when hiding the heading with showLabel={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
  • sm
  • md (default)
indent
  • 0 (default)
  • 1
  • 2
  • 3

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 bindable

Defaults 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
sm
md
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.