Skip to main content
Search
Search the documentation

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.json

The 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: —

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 id get 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 root and 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-primary rather 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».

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:

PropTypeDefaultDescription
headingsTocHeading[]read from DOM{ id, text, level }. Omit to collect them.
containerHTMLElement | nulldocument.bodyWhere headings are read from.
selectorstringh2, h3Which elements count.
rootHTMLElement | nullthe windowThe scrolling box the article lives in.
offsetnumber80Pixels below the top where a heading counts as reached.
activestring-Bindable id of the active heading.
onactivechange(id: string) => void-The active heading changed.
smoothbooleantrueSmooth scroll on click. Ignored under reduced motion.
minHeadingsnumber2Renders nothing below this many headings.
labelstringOn this pageNames the navigation and is rendered above the list.
classstring-Merged onto the <nav>. Carries [--coral-toc-indent:…].
itemClassstring-Merged onto every link.
headingSnippet-Replaces the default label rendered above the list.
itemSnippet<[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