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. Install
Add
toc-railto the site or starter that renders your long-form pages. - 2. Mount Point it at the article and the headings that already have stable fragment IDs.
-
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.