scroll-rail

A dependency-free scroll-position navigation rail: a compact column of ticks pinned to one edge of a scroll container, one per stop, with hover previews and click-to-scroll.

Install: npx shadcn@latest add @lloydhumphreys/scroll-rail

Position
Ticks
Theme
Active:

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.