Table of contents
A table of contents that highlights the section being read, including the last one.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-toc.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-toc.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-toc.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-toc.jsonThe hand-rolled version tracks the active heading with an IntersectionObserver and takes
whichever entry reports itself as visible, which answers "what is on screen" when the question is
"what have I scrolled past". This site's own sidebar still does it that way, and shows both holes:
a section short enough to sit entirely above the band never lights up, and the last heading on a
page can never win at all, because at the bottom of the document there is nothing left to scroll
and it never crosses the line.
Installation
Copy the folder into your project and add the shadcn primitives its manifest declares. From that moment the code is yours, versioned with the rest of the repository.
Usage
Import the component by its file path - there are no barrels. Pass a container when the article lives inside a panel with its own scrollbar, as it does in this demo.
Props
Everything the wrapped element accepts stays available. What Coral adds is the active-heading calculation and the anchors the markup was missing.
Accessibility
The active link carries aria-current="location", and after a smooth scroll focus lands on the heading rather than back at the top of the page.
Active: —
<script lang="ts">
import Toc from '#lib/components/coral/kit/toc/toc.svelte';
// The article scrolls inside its own box here, so the demo does not hijack the page. In a normal
// page you leave `root` out and the table of contents follows the window.
let article = $state<HTMLElement | null>(null);
let active = $state('');
const sections = [
[
'Installation',
'Copy the folder into your project and add the shadcn primitives its manifest declares. From that moment the code is yours, versioned with the rest of the repository.'
],
[
'Usage',
'Import the component by its file path - there are no barrels. Pass a container when the article lives inside a panel with its own scrollbar, as it does in this demo.'
],
[
'Props',
'Everything the wrapped element accepts stays available. What Coral adds is the active-heading calculation and the anchors the markup was missing.'
],
[
'Accessibility',
'The active link carries aria-current="location", and after a smooth scroll focus lands on the heading rather than back at the top of the page.'
]
];
</script>
<div class="flex w-full max-w-2xl gap-8">
<div
bind:this={article}
class="h-64 flex-1 scroll-pt-4 overflow-y-auto rounded-lg border p-4 [&_h2]:scroll-mt-0"
>
{#each sections as [title, body] (title)}
<h2 class="mt-4 text-base font-medium first:mt-0">{title}</h2>
<p class="mt-2 mb-10 text-sm text-muted-foreground">{body}</p>
{/each}
</div>
<div class="w-44 shrink-0">
<Toc container={article} root={article} offset={24} bind:active minHeadings={1} />
<p class="mt-3 text-xs text-muted-foreground">Active: {active || '—'}</p>
</div>
</div>What Coral adds
- Position, not intersection: the active heading is the last one past the boundary, measured on scroll. Short sections work, and entries arriving out of order cannot confuse it.
- The last section is reachable. At the bottom of the page or panel the final heading is active, which no boundary rule alone can produce.
- Anchors that exist: headings without an
idget a slugged one (accents folded, repeats numbered), so a table of contents over markdown or CMS HTML links somewhere. - Scrolls inside a panel too. Pass
rootand it follows that box instead of the window. - Focus follows a smooth scroll. After a smooth jump the heading is focused, so the next Tab continues from the section rather than from the top of the page.
- The familiar reading-line. A vertical guide runs the length of the list, and the active
segment picks up the theme's primary color - the same shape as the outline on Vercel, Next.js and
shadcn's own docs, built from
border-primaryrather than anything hardcoded.
Headings you already have
Overview
Content for «Overview».
Authentication
Content for «Authentication».
API keys
Content for «API keys».
Rotating a key
Content for «Rotating a key».
Rate limits
Content for «Rate limits».
<script lang="ts">
import Toc from '#lib/components/coral/kit/toc/toc.svelte';
import type { TocHeading } from '#lib/components/coral/kit/toc/types.js';
// Headings the page already knows about - from a CMS payload, or a markdown pipeline - so
// nothing is read back out of the DOM, and the ids are its doing rather than Coral's.
const headings: TocHeading[] = [
{ id: 'demo-overview', text: 'Overview', level: 2 },
{ id: 'demo-authentication', text: 'Authentication', level: 2 },
{ id: 'demo-api-keys', text: 'API keys', level: 3 },
{ id: 'demo-rotation', text: 'Rotating a key', level: 3 },
{ id: 'demo-rate-limits', text: 'Rate limits', level: 2 }
];
let article = $state<HTMLElement | null>(null);
</script>
<div class="flex w-full max-w-2xl gap-8">
<div bind:this={article} class="h-56 flex-1 overflow-y-auto rounded-lg border p-4">
{#each headings as heading (heading.id)}
<svelte:element
this={heading.level === 2 ? 'h2' : 'h3'}
id={heading.id}
class="mt-4 font-medium first:mt-0 {heading.level === 3 ? 'text-sm' : 'text-base'}"
>
{heading.text}
</svelte:element>
<p class="mt-2 mb-6 text-sm text-muted-foreground">Content for «{heading.text}».</p>
{/each}
</div>
<Toc
{headings}
root={article}
offset={8}
class="w-44 [--coral-toc-indent:1.25rem]"
label="On this page"
/>
</div>Pass headings and nothing is read from the DOM. That's the right shape when a markdown pipeline
or a CMS payload already lists them, and the ids are its doing.
Offset
offset is how far below the top of the box a heading counts as reached. Set it to the height of a
sticky header, or a heading parked underneath one still counts as unread. It pairs with scroll-margin-top on the headings themselves, which is what keeps the jump from putting the
heading under that same header.
Import
<script lang="ts">
import Toc from '#lib/components/coral/kit/toc/toc.svelte';
</script>Props
Everything a <nav> accepts stays available. On top of that:
| Prop | Type | Default | Description |
|---|---|---|---|
headings | TocHeading[] | read from DOM | { id, text, level }. Omit to collect them. |
container | HTMLElement | null | document.body | Where headings are read from. |
selector | string | h2, h3 | Which elements count. |
root | HTMLElement | null | the window | The scrolling box the article lives in. |
offset | number | 80 | Pixels below the top where a heading counts as reached. |
active | string | - | Bindable id of the active heading. |
onactivechange | (id: string) => void | - | The active heading changed. |
smooth | boolean | true | Smooth scroll on click. Ignored under reduced motion. |
minHeadings | number | 2 | Renders nothing below this many headings. |
label | string | On this page | Names the navigation and is rendered above the list. |
class | string | - | Merged onto the <nav>. Carries [--coral-toc-indent:…]. |
itemClass | string | - | Merged onto every link. |
heading | Snippet | - | Replaces the default label rendered above the list. |
item | Snippet<[TocItemContext]> | - | Replaces each link. Spread props. |
Accessibility
A <nav> with an accessible name and a list of real links, so it is reachable as a landmark and
every entry works with the keyboard, with middle-click and with "open in new tab". The active link
carries aria-current="location", because the section is where the reader is inside this document,
not a different page.
Under prefers-reduced-motion, the click is left to the browser: it jumps, moves focus into the
section and writes the hash, which is exactly the behaviour to fall back to.
headings.ts
slug, uniqueId and pickActive are exported on their own. pickActive is the whole
scroll-spy rule, and it is a pure function over numbers:
import { pickActive } from '#lib/components/coral/kit/toc/headings.js';
pickActive([-260, 40, 540], 80); // 1 - the last heading above the boundary
pickActive([-900, -600, -100], 80, true); // 2 - at the end of the page, the last one