toc-rail demo

Add a reading rail to an article

Import the CSS, mount the rail on your article, and let it keep the outline, active section, and reading progress in sync.

  1. 1. Install Add toc-rail to the site or starter that renders your long-form pages.
  2. 2. Mount Point it at the article and the headings that already have stable fragment IDs.
  3. 3. Clean up Keep the returned handle and call unmount() when a route leaves the page.

Install

Install the package and import the default stylesheet once on the client page.

npm install toc-rail

Import the CSS

import "toc-rail/style.css";

The stylesheet owns the rail layout, progress line, active state, and responsive hiding tokens.

Mount

Call mountTocRail() after the article exists in the DOM. mountReadingRail() is available as an alias.

import { mountTocRail } from "toc-rail";
import "toc-rail/style.css";

const rail = mountTocRail({
  content: "article",
  headings: "article h2[id], article h3[id]",
  minWidth: 800,
  topOffset: 56
});

Use stable heading IDs

The rail links are normal hash links. In SSR and hydrated apps, explicit IDs keep URLs stable across renders.

<h2 id="mount">Mount</h2>
<h3 id="mount-headings">Use stable heading IDs</h3>

Reader state

While the page scrolls, toc-rail measures the article and writes active state to the matching rail item.

The rail does not read layout on every paint. It caches heading positions and only updates the visible state during scroll frames.

Active section

The active link receives is-active, data-toc-rail-active="true", and aria-current="location".

Use that state for a quiet current-section treatment, not for a second navigation system.

For dense outlines, keep enough body copy between headings so the active state has room to settle while someone scrolls.

Progress line

The vertical line fills as the reader moves through the content. It is CSS-driven, so you can restyle it without touching JavaScript.

The progress value is exposed on the rail root for debugging and on the fill element for the default CSS transform.

Style

Override only the tokens your site needs. This demo leaves the package accent alone so the default rail is the thing being shown.

.toc-rail {
  --toc-rail-width: 184px;
  --toc-rail-right: 2rem;
  --toc-rail-top: max(96px, 18vh);
}
CSS token reference

Customization

  • --toc-rail-accent
  • --toc-rail-line
  • --toc-rail-muted
  • --toc-rail-faint
  • --toc-rail-text
  • --toc-rail-title
  • --toc-rail-width
  • --toc-rail-top
  • --toc-rail-right
  • --toc-rail-left
  • --toc-rail-z-index
  • --toc-rail-font-family
  • --toc-rail-title-size
  • --toc-rail-link-size
  • --toc-rail-link-weight
  • --toc-rail-link-line-height
  • --toc-rail-link-indent
  • --toc-rail-link-nested-indent
  • --toc-rail-panel-bottom-gap
  • --toc-rail-list-bottom-gap
  • --toc-rail-list-end-space

Runtime / read-only

  • --toc-rail-edge-opacity
  • --toc-rail-edge-offset
  • --toc-rail-visibility-delay
  • --toc-rail-progress

Placement

Move the rail with --toc-rail-left, --toc-rail-right, and --toc-rail-top.

Keep enough space between the article and the rail so the outline stays readable at narrower desktop widths.

The demo uses only CSS variables for placement, matching the way a blog or documentation layout would tune the package.

Color

Use --toc-rail-accent only when the site needs a different brand color. No runtime theme object is required.

The default accent follows the package baseline. Override one CSS variable when a site needs its own brand color.

Dynamic pages

The handle is there for SPAs, markdown previews, CMS previews, and docs pages that replace content after navigation.

rail.refresh(); // headings or article height changed
rail.update();  // viewport or scroll state changed
rail.unmount(); // route is leaving

Refresh after content changes

Call refresh() after images, embeds, or generated headings change the article height.

The rail also listens for resizes and late-loading fonts, but explicit refreshes are useful after client-rendered markdown changes.

If your CMS swaps the article without a full page load, refresh after the new headings are in the DOM.

Unmount on route changes

unmount() removes the DOM node, listeners, timers, animation frames, observers, and image load hooks.

That makes route cleanup predictable in Astro islands, React effects, Vue mounts, and other client-side page transitions.

Progress only

Pass headings: false when you want the progress line without links.

mountTocRail({
  content: "article",
  headings: false,
  title: false
});

Final check

Scroll the page, click a rail link, resize below minWidth, and call unmount() in your route cleanup path.

In this demo, the right rail is the actual package output. The article text is only here to make the behavior visible.