The scroll rail
Hover the tick column at the edge to preview sections; click to jump.
Overview
Long documents and threads outgrow their scrollbar. The scrollbar tells you where you are as a fraction of total pixels, but nothing about what is there — which section, which message, which step. The scroll rail replaces that guesswork with a compact column of ticks, one per stop, pinned to the edge of the scroll container.
The tick for the stop you are currently reading is highlighted. Pointing anywhere along the rail engages the nearest tick and shows a small preview card with the stop's title and a snippet of its content. Clicking smooth-scrolls you there.
Why not a table of contents?
A sidebar outline needs horizontal room that a chat pane, editor, or embedded viewer rarely has. The rail costs about twenty pixels, stays out of the way at half opacity until you scroll or reach for it, and still communicates the document's shape — longer ticks are higher-level headings, shorter ones are subsections.
Anatomy
Three pieces: the track of ticks, the floating preview card, and a headless observer that tracks the active stop. The whole strip is interactive — the pointer engages the tick nearest the cursor, so the gaps between ticks never drop your hover, and the preview card glides between positions instead of blinking off and on.
Behaviour
Everything is driven by two primitive ideas: which stop is active, and which tick the pointer is nearest to.
Active tracking
A stop is active once its target's top has scrolled to within the activation offset (default 96px) of the container top; the active stop is the last one to have crossed. Before the first stop crosses, the first is active. This matches how you actually read: the heading you most recently scrolled past is the section you are in.
Hover and the preview card
The card shows the stop's label plus an optional preview — a plain string or any DOM node of your own, cloned on show. For heading-derived stops, the preview is the first ~150 characters of prose following the heading.
Gliding between ticks
When the card is already visible and the engaged tick changes, only its vertical position transitions — the card slides along the rail rather than re-appearing. A fresh hover fades it in at the right spot with no slide from its last position.
Click to navigate
Clicking a tick — or the gap next to it — smooth-scrolls the stop's target into view. The rail rests at half opacity and brightens only while you're actually pointing at it, fading back the moment you stop hovering.
Theming
The rail ships with styles that read correctly in light and dark without configuration, and exposes CSS custom properties for everything worth overriding.
Custom properties
--rail-tick tick color (currentColor)
--rail-accent active-tick color (#3b82f6)
--rail-card-bg preview background (Canvas)
--rail-card-fg preview text (CanvasText)
--rail-card-border preview border (20% of text)
--rail-card-width preview width (234px)
Set any of these on the rail host or an ancestor. Ticks inherit the surrounding text color by default, so they already match your palette.
Dark mode
Try the theme toggle above. Nothing in the rail is re-configured when it flips.
System colors
The preview card uses the CSS Canvas and CanvasText system
colors, which resolve through color-scheme — so the card follows your
page's light/dark setting for free.
Color-coded ticks
Each stop accepts an optional color, useful for coding ticks by category,
status, or persona. Flip the “Ticks” toggle above to color each top-level section of
this document. A colored tick keeps its own color when active instead of falling back
to the accent.
API
The core is framework-agnostic vanilla DOM with no dependencies and no build step. A
thin React wrapper (<ScrollRail> and useActiveStop) is
included for React projects.
createScrollRail
const rail = createScrollRail({
scrollContainer: scroller,
items: headingItems(article),
position: 'right',
})
frame.appendChild(rail.element)
Append the returned element into a position: relative box wrapping your
scroll container; the rail pins itself to that box's edge and spans its height.
observeActive
The headless active-stop tracker behind both the rail and the React hook. Give it the scroll container, the stops, and a callback; build your own UI on top — a minimap, a progress meter, a synced outline.
The heading adapter
Most documents want one stop per heading, so two helpers cover that directly.
headingItems
Queries a container for headings (default h1, h2, h3), assigns slug ids to
any that lack one, and returns rail stops with levels and text previews — it is what
drives this very page.
itemsFromHeadings
The same mapping for an array of heading elements you already hold, when you need to filter or order them yourself.
Accessibility
Ticks are real buttons with the stop's label as their accessible name. Focusing a tick with the keyboard shows the same preview card as hover, and the card only hides when focus leaves the rail entirely, so Tabbing between ticks doesn't flicker. Activation works with Enter and Space like any button.