Trail
A contents rail for long pages, measured in reading time, with a reading line across the page. Collapsed, the rail is the article at scale: one tick per equal share of its reading time, a longer tick where a section starts, the ticks behind the violet dot one step darker. Open, it lists the titles with a bar for each section's reading time and says how much is left.
This package is the component that ships. The HTML prototypes (v1 to v6) stay in Lab as reference.
Files
src/
model.ts the pure part: reading time, rail layout, bars, progress, springs, text. No DOM.
trail.ts the rail: builds, measures, animates, handles pointer, keyboard and touch
line.ts the reading line across the page
dom.ts element builder (no innerHTML anywhere), block sampling
strings.ts every word Trail shows, in English
storage.ts where a reader's choices are kept
react.tsx the React wrapper
trail.css the styles, exilinar III tokens only
index.ts
test/
model.test.ts the model (Vitest)
react.test.tsx the wrapper rendered on the server (Vitest)
trail.e2e.ts the flows in Chromium (Playwright)
fixture/ the pages the flows run on
Put the folder where exilinar keeps its components. The package name in package.json is a placeholder.
Use it
React (the app, ramtt.com, docs):
import { Trail } from 'trail/react';
import 'trail/trail.css';
<aside className="rail-column"> {/* sticky, with a z-index above the text */}
<Trail
headings="#article h2, #article h3"
article="#article"
items={outline} // [{ id, title }] from the content build, rendered on the server
preset="read" // 'write' in an editor
storage={settingsStore} // optional; localStorage by default
contentKey={slug} // change it when the article changes
/>
</aside>
Without React:
import { Trail } from 'trail';
const trail = new Trail(document.querySelector('nav.rail')!, { headings: '#doc h2, #doc h3', article: '#doc' });
// later
trail.destroy();
The React wrapper renders the landmark and, if items is given, the outline as plain links, visually hidden. Search engines, screen readers and readers without JavaScript get the outline from the server; the rail replaces it on mount. React never renders children into the .trail element, so the two never fight over the DOM.
Options
| Option | Default | |
|---|---|---|
headings | 'h2' | selector run inside root, or the elements |
article | closest article | the element whose end and reading time count |
measure | 'time' | 'length' measures in page height instead |
wpm | 238 | silent reading of non-fiction, Brysbaert 2019 |
length | 0.5 | rail length as a share of the window |
bars | true | open: tick length is the section's reading time |
sweep | true | collapsed: the tick below the dot fills as the dot moves towards it |
fade | true | a long open list fades at the ends that have more |
skim | true | collapsed: the section under the pointer is named; resting 700 ms opens |
preset | 'read' | 'read' or 'write', see below |
storage | localStore | a TrailStorage adapter |
strings | English | replace any of the words |
Methods: refresh(), measure(), set(feature, on), setMeasure(), setPreset(), setLine(partial, save), resetLine(), hold(on), destroy(). Getters: options, readingLine, current. The root element fires trail:line with the line state whenever it changes.
Defaults
| Where | preset | Placement |
|---|---|---|
| ramtt.com articles, Current, Field Guide, docs | read | beside the text column |
| The docs editor, Zibaldone and documents in the app, while writing | write | 16 px from the left edge of the content pane |
| The same documents opened to read | read | 16 px from the left edge of the content pane |
read turns the reading line on with motion, mark and fill. write turns it off: the cursor is where a writer is, a line across the text competes with the cursor and the selection, and dim would fade the paragraph being edited. The reader can change the line from the settings button in the open rail. Their choice is kept per preset, so a choice made while reading does not change the editor.
Host rules
1. The rail's container needs a z-index above the text. Text faded by dim gets its own stacking context and would otherwise paint over the panel and the settings. 2. Give the rail a sticky container. Trail keeps its length inside the room below the sticky top. 3. Trail reads these exilinar tokens: paper, raised, ink, ink-2 to ink-4, a5, a35, line-4, accent-glyph, e2, px, r-row, font, fs-12, lh-12, tracking. 4. Every word Trail shows must be in VOCABULARY.md before it ships. The list is in VOCABULARY-ENTRIES.md.
What is tested
- The model: reading time for text, figures and code, the rail layout at normal, many and very many headings,
heading rows, the bar scale, progress through a section and through the gap above a heading, the row state and the sweep, the dot spring at 30, 60 and 120 fps, the words for time, ids from titles, stored line state.
- The wrapper rendered on the server: the landmark, the outline as links, escaped titles, an empty rail element.
- In Chromium: ticks per heading and nothing hidden shown; scrolling marks ticks read and moves the current
section; skim names the section and opens only on rest; a pointer crossing the rail does not open it; the settings open from the button, change and keep the line, and close with the panel; Escape closes the settings and then the panel; one tab stop on the current section, arrows, Enter; the reading line's fill, mark and drag; the write preset; destroy leaves no line and no listeners; touch taps; the React wrapper mounts over the outline and cleans up on unmount.
pnpm typecheck # tsc, strict
pnpm test # vitest
pnpm e2e # builds the fixtures, then playwright
Notes
'use client'is at the top of the React file for server component frameworks.- The motion runs on
requestAnimationFrameand CSS variables, not React state, and stops when nothing moves. prefers-reduced-motionturns the springs into jumps; the sweep still fills, since it moves only as fast as the
reader scrolls.