Scrubber Cursor
A draggable vertical cursor that walks the full width of its host container. Position is a normalized scalar in [0, 1] — 0 is the left edge, 1 is the right edge — so the same component drops onto any timeline, waveform, or sliding-window track without caring about the underlying pixel width. Drag the handle, or arrow-key it, to scrub the position.
Installation
npx shadcn@latest add https://craftbits.dev/r/scrubber-cursor.jsonUsage
Uncontrolled — let the scrubber own its position:
import { ScrubberCursor } from "@craft-bits/core";
<div className="relative h-32 w-full">
<ScrubberCursor defaultPosition={0.4} height={128} />
</div>Controlled — pair position with onPositionChange so a parent owns the scrub state:
const [position, setPosition] = useState(0.4);
<ScrubberCursor
position={position}
onPositionChange={setPosition}
height={128}
badge={Math.round(position * 10)}
caption={"t=" + Math.round(position * 10)}
/>Bind the glow halo to a data signal (e.g. an overlap ratio or energy reading) so the cursor brightens as the underlying signal peaks:
<ScrubberCursor
position={position}
onPositionChange={setPosition}
glow={overlapRatio}
tone="success"
/>Understanding the component
- Normalized position. The scrubber's source of truth is a single number in
[0, 1]. Consumers never have to translate to pixels — the component reads its host's width at mount, observes it viaResizeObserver, and maps the drag pointer onto the unit interval. Resize the parent and the cursor lands on the same fraction without any extra wiring. - Drag + keyboard at once. Drag motion uses the
drag="x"API with constraints pinned to the track's width. A visually-hidden native<input type="range">is layered over the track for keyboard a11y — Tab focuses, arrow keys step bystep, Home / End jump to the bounds. Screen readers announce the percentage viaaria-valuenow/aria-valuetext. - Controlled + uncontrolled. Pair
positionwithonPositionChangefor fully controlled use (parent narration drives the scrubber), or passdefaultPositionalone for a free-running scrubber that remembers its own state. - Tone drives every color.
toneselects a--cb-*variable for the line, the handle, and the glow halo. Theme swaps and dark mode repaint without any prop overrides on the call-site. - Glow as data.
glowin[0, 1]controls the outer halo intensity. Bind it to a continuous signal — overlap count, energy reading, attention weight — so the cursor visually "peaks" at the same moment the underlying data does. Default0keeps the cursor at rest. - Reduced motion. Under
prefers-reduced-motion: reduce, the snap transition collapses toduration: 0— the cursor still moves, but instantly.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
position | number | — | Controlled position in [0, 1]. Pair with onPositionChange. |
defaultPosition | number | 0 | Uncontrolled initial position in [0, 1]. |
onPositionChange | (position: number) => void | — | Fired with the new normalized position after drag or keyboard step. |
height | number | 120 | Height of the cursor line in pixels. |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Semantic color for line, handle, and glow. |
badge | ReactNode | — | Optional content rendered inside the circular handle. |
caption | ReactNode | — | Optional caption rendered below the cursor line. |
glow | number | 0 | Glow intensity in [0, 1] — brightens the outer halo. |
step | number | 0.01 | Arrow-key step size in [0, 1] units. |
disabled | boolean | false | Disables drag + keyboard interaction. |
ariaLabel | string | inferred | Override the accessible name. |
className | string | — | Merged onto the outer <div>. |
Accessibility
- A visually-hidden native
<input type="range">provides the slider role + keyboard handling — Tab focuses, Arrow-Left / Right step bystep, Home / End jump to the bounds. aria-valuemin,aria-valuemax,aria-valuenow, andaria-valuetextmirror state so assistive tech announces the percentage, not the raw0..1value.- The default
aria-labelreads"Scrubber at NN percent". Override via theariaLabelprop when the scrubber's purpose is more specific. - The visual cursor, badge, and caption are wrapped in
aria-hidden="true"containers — the slider role already announces the value, so the decorative chrome stays out of the screen-reader stream. - Drag animations respect
prefers-reduced-motion: reduceand collapse to instant transitions.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/chrome/ScrubberCursor.tsx). The original was a compoundScrubberCursor.Interactive+ScrubberCursor.Sweepnamespace coupled to lesson-scoped semantic hex tokens and pixel-codedleftstrings. craft-bits collapses the two variants into one drag-or-key scrubber whose position is a single[0, 1]scalar.