A sticky, animated table of contents for Svelte articles and docs. It renders heading links, tracks the active section while scrolling, and shows a vertical reading progress bar.
By default, the component scans your rendered content for headings. You can also pass heading metadata from your own Markdown, MDsveX, CMS, or content pipeline.
bun add @smit4k/svelte-reading-toc
npm install @smit4k/svelte-reading-toc
For plain rendered HTML, point the component at the article content:
<script lang="ts">
import { ReadingTableOfContents } from '@smit4k/svelte-reading-toc';
</script>
<div class="post-shell">
<div class="toc-slot">
<ReadingTableOfContents contentSelector=".content" />
</div>
<article class="content">
<h2>Intro</h2>
<p>...</p>
<h2>Setup</h2>
<p>...</p>
<h3>Details</h3>
<p>...</p>
</article>
</div>
The component scans h2, h3, and h4 by default. If a heading does not have an id, one is generated so the sidebar links work.
If you already collect headings while rendering Markdown, pass them explicitly. Provided headings, including an empty array, are used instead of scanning the DOM:
<script lang="ts">
import { ReadingTableOfContents, type TocHeading } from '@smit4k/svelte-reading-toc';
const headings: TocHeading[] = [
{ id: 'intro', text: 'Intro', level: 2 },
{ id: 'setup', text: 'Setup', level: 2 },
{ id: 'details', text: 'Details', level: 3 }
];
</script>
<div class="post-shell">
<div class="toc-slot">
<ReadingTableOfContents {headings} contentSelector=".content" />
</div>
<article class="content">
<h2 id="intro">Intro</h2>
<p>...</p>
<h2 id="setup">Setup</h2>
<p>...</p>
<h3 id="details">Details</h3>
<p>...</p>
</article>
</div>
| Prop | Type | Default | Description |
|---|---|---|---|
headings |
TocHeading[] |
undefined |
Optional heading metadata. If provided, DOM scanning is skipped. |
contentSelector |
string |
'article' |
Element used to calculate reading progress. |
headingSelector |
string |
'h2, h3, h4' |
Headings to scan when headings is not provided. |
generateIds |
boolean |
true |
Add IDs to scanned headings that do not already have one. |
idPrefix |
string |
'heading' |
Fallback prefix when a scanned heading has no usable text. |
minHeadings |
number |
2 |
Hide the TOC until at least this many headings exist. |
hideBelow |
number |
900 |
Hide the TOC below this viewport width in pixels. |
ariaLabel |
string |
'Table of contents' |
Accessible label for the <aside>. |
class |
string |
'' |
Extra class names for the root element. |
export type TocHeading = {
id: string;
text: string;
level: number;
};
The component ships with neutral defaults and exposes CSS variables for theming.
.toc-slot {
--toc-width: 14rem;
--toc-top: 1rem;
--toc-text: #898989;
--toc-text-hover: #d6d6d6;
--toc-text-active: #ffffff;
--toc-active-bg: rgba(255, 255, 255, 0.1);
--toc-hover-bg: rgba(255, 255, 255, 0.04);
--toc-progress-bg: #242424;
--toc-progress-fill: #ffffff;
}
For a left sidebar that does not shift article content:
.post-shell {
position: relative;
max-width: 65ch;
margin: 0 auto;
padding: 0 1rem;
}
.toc-slot {
position: absolute;
top: 0;
right: calc(100% + 1.25rem);
bottom: 0;
width: 14rem;
}
You can let the component scan rendered Markdown output:
<ReadingTableOfContents contentSelector=".content" />
<article class="content">
{@html htmlFromMarkdown}
</article>
Or generate headings wherever you already turn Markdown into HTML. In explicit mode, every heading object must have an id matching the rendered heading:
<h2 id="setup">Setup</h2>
const headings = [{ id: 'setup', text: 'Setup', level: 2 }];
bun install
bun run check
bun run package