Chapter scrubber

A rail of marks, one per chapter, that swell under the pointer like the macOS Dock. The tallest mark, the crest, previews its chapter, so people can jump around a long piece without a table of contents.

Features

  • Marks swell in a wave around the pointer or keyboard focus, and the crest previews its chapter as a card, a label, or nothing.
  • Runs vertically in a gutter or horizontally along a player, with ticks or dots in three densities.
  • Works controlled or uncontrolled, so the marker can follow scroll or playback.
  • Respects reduced motion: the springs switch off, and the wave keeps its shape but appears and follows instantly.

Installation

Installnpx shadcn@latest add https://fibo.toribryan.com/r/chapter-scrubber.json
Importimport { ChapterScrubber, type Chapter } from "@/components/ui/chapter-scrubber"

It installs motion with it, for the springs.

Usage

import { ChapterScrubber } from "@/components/ui/chapter-scrubber"
 
const chapters = [
  { id: "intro", title: "Introduction" },
  { id: "tokens", title: "Token tiers", description: "Ramps, then roles." },
  { id: "figma", title: "Figma variables", meta: "12:40" },
]
 
export function Contents() {
  return <ChapterScrubber chapters={chapters} />
}

Chapters sit on the rail in array order, one mark each. The title and description name each mark for screen readers.

Each chapter takes:

type Chapter = {
  id: string
  title: string
  description?: React.ReactNode // clamped to three lines
  meta?: React.ReactNode // a small label beside the title, such as a timestamp
}

Anatomy

Every part the component renders, with the data-slot it carries. You pass chapters; the component builds the rest.

Partdata-slotNotes
ChapterScrubberchapter-scrubberThe root. Carries data-orientation and data-variant.
railThe listbox that holds the items.
itemchapter-scrubber-itemOne per chapter, in array order. The option people focus and click.
markThe tick or dot. Carries data-current on the current chapter.
previewchapter-scrubber-previewBeside the crest. Shows the chapter's meta, title and description.

Guidelines

  • 01Use it for content with a real sequence: a talk, a long read, a case study, a recording.
  • 02Keep between six and thirty chapters. Fewer reads as a list; more stops the wave from reaching the neighbours.
  • 03Pick card when chapters have descriptions, label when the title says enough, and none when the page already names the section.
  • 04Leave side unset in most layouts. It prefers right or top and flips on its own near the viewport edge.
  • 05Set currentIndex from your scroll or playback position so the marker keeps up with the reader between clicks.

Examples

Horizontal

A rail along the top of a player or a slide deck. Ticks stand up from the baseline and the preview opens above.

Dots

variant="dot" swaps ticks for dots, for a softer rail that sits well beside imagery.

Centred ticks

align="center" grows ticks out from the middle line, which balances a rail placed on its own.

Sizes

size sets the density: sm, default and lg. rowSize, restLength and peakLength override it when you need an exact rhythm.

sm
default
lg

Controlled

Buttons elsewhere on the page drive the same marker the rail sets, through currentIndex and onCurrentIndexChange.

08:02 · 4 of 14Semantic roles

In an article

The rail in a sticky column beside a long read.

01 / 07

The problem

A long read sits to the right of a quiet rail. At rest it is a column of hairlines; under the pointer the marks swell and name the section, so the reader sees the whole shape of the piece and can jump without a table of contents taking up the margin.

Do's and don'ts

DoGive it enough chapters for the wave to have neighbours to lift.
Don'tDon't use it for three items. Plain links say more in less space.
Semantic roles
DoKeep titles short enough to read in the moment the crest passes.
In which we finally discuss how semantic roles came to be named
Don'tDon't put the chapter's argument in its title. That belongs in the description.

API reference

PropTypeDefaultDescription
chaptersChapter[]Chapters in order along the rail, one mark each.
orientation"vertical" | "horizontal""vertical"Which way the rail runs.
variant"tick" | "dot""tick"Mark style: hairline ticks or dots.
size"sm" | "default" | "lg""default"Density preset. Numeric props below override it.
side"left" | "right" | "top" | "bottom"Where the preview opens. Vertical rails take left or right, horizontal rails top or bottom. Unset picks right or top, and it flips when the preferred side would leave the viewport.
align"edge" | "center""edge"Ticks grow from the rail's edge, or out from its centre line.
preview"card" | "label" | "none""card"What shows beside the crest.
rowSizenumberRow pitch along the rail, in pixels.
restLengthnumberResting mark length (or dot diameter), in pixels.
peakLengthnumberMark length (or dot diameter) at the crest, in pixels.
radiusnumber4How far the wave reaches from the pointer, in rows.
currentIndexnumberThe chapter marked as current. Pass it to control the marker.
defaultCurrentIndexnumberThe starting current chapter when uncontrolled.
onCurrentIndexChange(index: number, chapter: Chapter) => voidFires when a chapter is chosen, with the new current index.
onActiveChange(chapter: Chapter | null, index: number) => voidFires as the hovered or focused chapter changes; null on leave.
labelstring"Chapters"Accessible name for the rail.

Any other prop goes to the root div.

Data attributes

Style parts by slot or state, for example with [&_[data-slot=chapter-scrubber-preview]]:shadow-none.

AttributeElementPresent when
data-slot="chapter-scrubber"The rootAlways.
data-slot="chapter-scrubber-item"Each chapter's row on the railAlways.
data-slot="chapter-scrubber-preview"The card or label beside the crestUnless preview is none. It fades in while a chapter is hovered or focused.
data-orientationThe rootAlways. vertical or horizontal.
data-variantThe rootAlways. tick or dot.
data-currentThe mark of the current chapterThe chapter is current.

Accessibility

  • 01The rail is a listbox and every mark an option named by its title and description. The current chapter is aria-selected.
  • 02Only one mark is in the tab order. Arrow keys, Home and End move along the rail; Enter or Space makes a chapter current.
  • 03Focus raises the wave exactly as hover does, so keyboard users get the same preview.

References