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.json

Usage

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

  1. 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 via ResizeObserver, and maps the drag pointer onto the unit interval. Resize the parent and the cursor lands on the same fraction without any extra wiring.
  2. 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 by step, Home / End jump to the bounds. Screen readers announce the percentage via aria-valuenow / aria-valuetext.
  3. Controlled + uncontrolled. Pair position with onPositionChange for fully controlled use (parent narration drives the scrubber), or pass defaultPosition alone for a free-running scrubber that remembers its own state.
  4. Tone drives every color. tone selects 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.
  5. Glow as data. glow in [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. Default 0 keeps the cursor at rest.
  6. Reduced motion. Under prefers-reduced-motion: reduce, the snap transition collapses to duration: 0 — the cursor still moves, but instantly.

Props

PropTypeDefaultDescription
positionnumberControlled position in [0, 1]. Pair with onPositionChange.
defaultPositionnumber0Uncontrolled initial position in [0, 1].
onPositionChange(position: number) => voidFired with the new normalized position after drag or keyboard step.
heightnumber120Height of the cursor line in pixels.
tone"default" | "accent" | "success" | "warning" | "error""accent"Semantic color for line, handle, and glow.
badgeReactNodeOptional content rendered inside the circular handle.
captionReactNodeOptional caption rendered below the cursor line.
glownumber0Glow intensity in [0, 1] — brightens the outer halo.
stepnumber0.01Arrow-key step size in [0, 1] units.
disabledbooleanfalseDisables drag + keyboard interaction.
ariaLabelstringinferredOverride the accessible name.
classNamestringMerged onto the outer <div>.

Accessibility

  • A visually-hidden native <input type="range"> provides the slider role + keyboard handling — Tab focuses, Arrow-Left / Right step by step, Home / End jump to the bounds.
  • aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext mirror state so assistive tech announces the percentage, not the raw 0..1 value.
  • The default aria-label reads "Scrubber at NN percent". Override via the ariaLabel prop 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: reduce and collapse to instant transitions.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/chrome/ScrubberCursor.tsx). The original was a compound ScrubberCursor.Interactive + ScrubberCursor.Sweep namespace coupled to lesson-scoped semantic hex tokens and pixel-coded left strings. craft-bits collapses the two variants into one drag-or-key scrubber whose position is a single [0, 1] scalar.