ReadingTOC

Auto-tracking document table of contents.

Live demo

01
Variant
Size
Rail position

Introduction

A reading table of contents quietly improves long-form reading on the web. Readers get a persistent map of the page β€” a sense of where they are now, and a one-click escape to anywhere else.

Why pair it with ScrollProgressBar

The progress bar answers how much remains?; the TOC answers what am I reading? Together they form a lightweight reading shell that doesn't compete for attention.

When not to use it

Short pages and landing pages don't benefit. Use this for single long documents with three or more headings worth jumping between.

Variants

Three layouts cover the common cases β€” rail, top pills, and drawer.

Rail

The default. A sticky vertical column anchored to the left or right edge. Hierarchy is shown via indented sub-lists.

Top

A horizontal scrolling pill row at the top of the article. Renders only top-level headings.

Drawer

A FAB that opens a panel containing the full hierarchy β€” good for mobile or content-dense layouts.

Active tracking

A single IntersectionObserver covers all tracked headings. The rootMargin is '0px 0px -60% 0px' so a heading becomes active as soon as it crosses the top 40% of the viewport.

Reduced motion

Under prefers-reduced-motion: reduce, click-to-scroll uses an instant scrollIntoView and CSS transitions are disabled.

Accessibility

The wrapper is a real nav; entries are anchors that work without JavaScript. The active item carries aria-current="location".

Performance

One observer, one click handler per item, one $derived heading-tree memo. No scroll listener, no ResizeObserver.

Recipes

Pair with a top-edge progress bar. Swap rail for drawer at small sizes. Use top pills for short docs.

Implementation

02
ReadingTOC.svelte
<script lang="ts">
  import ReadingTOC from '$lib/components/ReadingTOC.svelte';
</script>
​
<ReadingTOC target="#article" levels={[2, 3]} variant="rail" />
​
<article id="article">
  <h2>First section</h2>
  <p>…</p>
  <h3>A subsection</h3>
  <p>…</p>
  <h2>Second section</h2>
  <p>…</p>
</article>

ReadingTOC walks the target container once on mount, collects the requested heading levels, and registers each one with a single IntersectionObserver. The observer rootMargin is "0px 0px -60% 0px" so a heading is "active" the moment it enters the top 40% of the viewport. When several headings overlap, the resolver picks the highest intersection ratio and falls back to the previous active id past the last heading. Click handlers smooth-scroll (or instant-scroll under reduced motion) without breaking the underlying anchor link.

Logic explainer

03

What Does It Do? (Plain English)

ReadingTOC is an auto-tracking table of contents for long-form content. It scans a target container for headings, renders them as a hierarchical list, highlights the heading the reader is currently looking at, and smooth-scrolls to a section on click. As the reader scrolls past a heading, the active marker quietly follows them down the page.

Think of it as a you-are-here sticker for a magazine article. The reader can glance over, see which section they're inside, and jump anywhere with a single click. It pairs naturally with ScrollProgressBar β€” together they form a complete reading toolkit:

  • ScrollProgressBar β€” how much (passive position 0β†’100%)
  • ReadingTOC β€” where (active section awareness)

The component ships in three layouts so it can adapt to any page:

  • rail β€” a sticky vertical sidebar. Best for desktop layouts with a margin column. Hierarchy is shown via indented sub-lists with a connecting line.
  • top β€” a horizontal scrolling pill row. Best for short docs or top-of-page placement. Renders only top-level headings (children are ignored).
  • drawer β€” a fixed-position floating action button that opens an overlay panel. Best for mobile or content-dense pages where a permanent rail would crowd the layout.

How It Works (Pseudo-Code)

state:
  extractedHeadings = []           // populated on mount from `target` container
  activeId          = null         // id of the currently active heading
  drawerOpen        = false        // drawer variant only
  reduced           = false        // prefers-reduced-motion gate
  entryCache        = {}           // id β†’ { ratio, isIntersecting, top }

derived:
  headingsToUse = providedHeadings ?? extractedHeadings
  tree          = buildHeadingTree(headingsToUse)

on mount:
  reduced = isReducedMotion()
  if no providedHeadings:
    container = document.querySelector(target)
    extractedHeadings = extractHeadings(container, levels)
    // mutates DOM: assigns slugified ids to headings that lack one
  setupObserver()

setupObserver():
  observer = new IntersectionObserver(handleIntersect, {
    rootMargin: '0px 0px -60% 0px',
    threshold:  [0, 0.25, 0.5, 0.75, 1]
  })
  for each heading h:
    el = document.getElementById(h.id)
    if el: observer.observe(el)

on intersect(entries):
  for each entry e:
    entryCache[e.target.id] = snapshot(e)
  activeId = resolveActiveHeading(values(entryCache), activeId)

resolveActiveHeading(entries, fallback):
  visible = entries where isIntersecting
  if any visible: return one with highest ratio (tiebreak: top closest to 0)
  passed = entries where top < 0
  if any passed:  return one with greatest top (most recently passed)
  return fallback

on link click(id):
  prevent default
  el = document.getElementById(id)
  el.scrollIntoView({ behavior: reduced ? 'auto' : 'smooth' })
  activeId  = id
  history.replaceState(null, '', `#${id}`)
  if drawer variant: drawerOpen = false

on Escape (drawer variant only):
  drawerOpen = false

on headings change (effect):
  re-run setupObserver()  // disconnects, clears cache, re-observes

Helper Exports

The module-script exposes pure helpers for testing and downstream use. Every one of these is a plain function with no Svelte runtime dependency, which is what makes the component's logic so easy to unit-test.

Export Purpose
VALID_VARIANTS Read-only list of accepted variant names
VALID_SIZES Read-only list of accepted size names
isValidVariant(v) Type-guard for variant strings
pickVariant(v) Coerce to valid variant or fall back to 'rail'
isValidSize(s) Type-guard for size strings
pickSize(s) Coerce to valid size or fall back to 'md'
slugify(text) URL-safe slug; NFKD-strips diacritics, drops punctuation, hyphenates
buildHeadingTree(list) Stack-based hierarchy from a flat heading list (handles level skips)
flattenTree(nodes) Depth-first walk back to a flat list (inverse of buildHeadingTree)
resolveActiveHeading(...) Three-tier active resolver (intersecting β†’ passed β†’ fallback)
extractHeadings(el, lv) Read h-tags from a container; auto-assigns slugified IDs when missing
isReducedMotion() Detect prefers-reduced-motion: reduce safely

The resolveActiveHeading function is the heart of the tracker. It picks the active heading using a three-tier strategy: first preferring any heading currently intersecting the top band of the viewport (highest ratio wins, tiebreak by document order), then falling back to the most recently passed heading (largest negative top), and finally to the supplied fallback. This is why the active marker stays put even when you've scrolled past every heading on the page β€” there's always a sensible answer.

Performance

  • A single IntersectionObserver covers all tracked headings β€” never one observer per heading.
  • Top-band rootMargin: '0px 0px -60% 0px' makes the active item update as you read past a heading, not when it leaves the bottom of the viewport. The effect is that the highlight follows your eye line, not the heading's exit.
  • The hierarchical tree is rebuilt only when the headings list changes ($derived memoisation) β€” not on every observer tick.
  • The entry cache is a plain object keyed by id, not a Map. There's no per-frame churn, no reactive proxy overhead.
  • No scroll listener, no ResizeObserver, no MutationObserver, no requestAnimationFrame loop. The observer fires only when a heading actually crosses a threshold.

Distinct From ScrollProgressBar

These two components live next to each other in the docs and are deliberately complementary, not competing:

  • ScrollProgressBar is passive. It tells you you're 47% of the way through. It doesn't know what 47% means; it doesn't know about sections; it can't take you anywhere.
  • ReadingTOC is active. It knows which section you're in, lets you jump elsewhere, and rebuilds itself if the heading list changes.

Other things that are not ReadingTOC:

  • Pagination β€” discrete step navigation, not scroll-bound.
  • Stepper β€” multi-step form indicator, not document-driven.
  • StaggeredMenu β€” site-wide navigation, not in-page anchor list.

Recipes

Pair with ScrollProgressBar

<ScrollProgressBar variant="thin" color="#6366f1" />

<div class="page-grid">
  <ReadingTOC variant="rail" position="right" />
  <main>
    <article>...</article>
  </main>
</div>

Mobile drawer + desktop rail (CSS-driven)

<div class="md:hidden">
  <ReadingTOC variant="drawer" />
</div>
<div class="hidden md:block">
  <ReadingTOC variant="rail" />
</div>

Top pill row for short docs

<ReadingTOC variant="top" levels={[2]} title="Sections" />

Override extraction with a pre-built list

Useful when content is rendered into a container after onMount, or when you want to control which headings appear:

<script lang="ts">
  import ReadingTOC, { type Heading } from '$lib/components/ReadingTOC.svelte';

  const headings: Heading[] = [
    { id: 'overview', text: 'Overview', level: 2 },
    { id: 'install', text: 'Installation', level: 2 },
    { id: 'config', text: 'Configuration', level: 3 }
  ];
</script>

<ReadingTOC {headings} />

State Flow Diagram

                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚  mount                 β”‚
                β”‚  extractedHeadings = []β”‚
                β”‚  activeId   = null     β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚ if no providedHeadings:
                            β”‚   extractHeadings(target, levels)
                            β–Ό
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚  observe all heading   β”‚
                β”‚  elements              β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β”‚               β”‚               β”‚
            β–Ό               β–Ό               β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ heading enters  β”‚ β”‚ click on β”‚ β”‚ headings array β”‚
   β”‚ top band        β”‚ β”‚ link     β”‚ β”‚ changes        β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
            β”‚ ratio update  β”‚ scrollIntoView β”‚ disconnect +
            β”‚ resolve()     β”‚ activeId = id  β”‚ re-observe
            β–Ό               β–Ό                β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  activeId = <id>  β†’  aria-current="location"β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

   drawer variant only:
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”   click toggle   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ closed  β”‚ ────────────────▢│  open   β”‚
   β”‚         β”‚ ◀────────────────│         β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  Escape / link   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                 click

Props Reference

Prop Type Default Description
target string 'main' CSS selector for the container whose headings are extracted.
headings Heading[] undefined Pre-built heading list; overrides DOM extraction when supplied.
levels number[] [2, 3, 4] Which heading levels to track.
variant 'rail' | 'top' | 'drawer' 'rail' Visual layout.
size 'sm' | 'md' | 'lg' 'md' Type scale.
position 'left' | 'right' 'left' Rail anchor side (rail variant only).
title string 'On this page' Section heading shown above the list.
aria-label string 'Table of contents' Screen-reader announcement for the nav landmark.
class string '' Additional wrapper classes.

The Heading type:

interface Heading {
  id: string;
  text: string;
  level: number;  // 1–6
}

Edge Cases

Situation Behaviour
target selector matches nothing on mount extractedHeadings stays empty; rail/drawer renders an "No headings found." message.
Heading element has no id attribute extractHeadings mutates the DOM and assigns a slugified id (heading-0 if the text slugifies to empty).
Two headings produce the same slug Subsequent collisions get a numeric suffix (overview, overview-1, overview-2).
User scrolls past every heading Active marker stays on the most recently passed heading via the top < 0 fallback in resolveActiveHeading.
headings prop changes at runtime The $effect re-runs setupObserver(): disconnects, clears the entry cache, observes the new set.
prefers-reduced-motion: reduce scrollIntoView uses behavior: 'auto' (instant jump); panel fade-in and link transitions are killed.
IntersectionObserver unavailable (very old browsers) List renders, links work, but no auto-tracking β€” setupObserver returns early.
Heading level skip (h2 β†’ h4 with no h3) buildHeadingTree's stack pop keeps the h4 as a child of the h2; no orphan node.
Top variant with deeply nested headings Top variant only renders the top level of the tree; children are ignored by design.

Dependencies

  • Svelte 5.x β€” runes ($state, $derived, $effect) and snippets ({#snippet itemList} recurses through the tree).
  • Zero external dependencies β€” pure scoped CSS, native IntersectionObserver, no motion library.

File Structure

src/lib/components/ReadingTOC.svelte    # implementation (with module-script helpers)
src/lib/components/ReadingTOC.md        # this file (rendered inside ComponentPageShell)
src/lib/components/ReadingTOC.test.ts   # vitest unit tests for the helpers
src/routes/readingtoc/+page.svelte      # demo page

API

04
PropTypeDefaultDescription
targetstring (CSS selector)'main'Container whose headings are extracted.
headingsHeading[]undefinedOverride extraction with a pre-built list.
levelsnumber[][2, 3, 4]Which heading levels to track.
variant'rail' | 'top' | 'drawer''rail'Visual layout.
size'sm' | 'md' | 'lg''md'Type scale.
position'left' | 'right''left'Rail anchor side (rail variant only).
titlestring'On this page'Heading shown above the list.
aria-labelstring'Table of contents'Screen reader announcement.
classstring''Extra wrapper classes.