Scrubber

Updated: Sep 24, 2026
Scrubber is the media seek control. It owns the track, the handle, the elapsed and total timestamps, the focused-value tooltip, the interaction scrim, and all keyboard and pointer seeking. You supply a controlled position and two callbacks: one for every intermediate update and one for the committed value.
Position is expressed on a 0–100 scale — a percentage of the media, not seconds. Pass durationSeconds separately so the component can format times.

Usage

import { useState } from 'react';
import { Scrubber, ScrubberTimestampPosition } from '@wearables-ui-toolkit/mrbd';

type PlaybackScrubberProps = {
  positionPercent: number;
  durationSeconds: number;
  onSeek: (percent: number) => void;
};

export function PlaybackScrubber({
  positionPercent,
  durationSeconds,
  onSeek,
}: PlaybackScrubberProps) {
  const [preview, setPreview] = useState<number | null>(null);

  return (
    <Scrubber
      value={preview ?? positionPercent}
      durationSeconds={durationSeconds}
      timestampPosition={ScrubberTimestampPosition.BOTTOM}
      showTooltip
      onValueChange={setPreview}
      onValueChanged={next => {
        setPreview(null);
        onSeek(next);
      }}
      aria-label="Playback position"
    />
  );
}
The scrubber fills the width it is given. Give the surrounding row inline padding with spacing tokens rather than sizing the scrubber itself:
.player-controls {
  display: flex;
  flex-direction: column;
  gap: var(--uit-spacing-small);
  padding-inline: var(--uit-spacing-large);
}

Props

The full prop type is ScrubberProps.
PropTypeDefaultDescription
value
number
required
Current position on a 0–100 scale. Clamped into range; a non-finite value is treated as 0.
durationSeconds
number
0
Total media duration in seconds. Floored to a whole, non-negative number; a non-finite value becomes 0. 0 hides the timestamp row.
showTooltip
boolean
false
Show a tooltip anchored to the handle while the control is focused or being dragged.
hideScrim
boolean
false
Skip rendering the interaction scrim behind the control.
timestampPosition
ScrubberTimestampPosition
ScrubberTimestampPosition.BOTTOM
Place the elapsed and total timestamps above or below the track.
onValueChange
(value: number) => void
—
Live updates: every pointer move during a drag, and every keyboard step that changes the value.
onValueChanged
(value: number) => void
—
Committed value: once per keyboard step that changes the value, and once at the end of a pointer drag.
disabled
boolean
false
Disables seeking, removes the tab stop, dims the control, and marks the element as disabled.
tabIndex
number
0
Tab order of the control. Forced to -1 while disabled.
aria-label
string
'Media position'
Accessible name. Override with something specific to the media.
ScrubberProps also accepts the standard div attribute surface — className, style, dir, data-*, and DOM event handlers. role, aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext are removed from the accepted props: the component always supplies them. onChange is removed too — use onValueChange and onValueChanged.
Your own onFocus, onBlur, onKeyDown, onKeyUp, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, and onLostPointerCapture handlers are preserved and run first, before the component’s own handling.

Types and enums

ScrubberTimestampPosition
  • BOTTOM — timestamps sit below the track.
  • TOP — timestamps sit above the track.

Behavior

  • Controlled. The component never mutates value. Apply the callback value to your state and pass it back in.
  • Keyboard. ArrowRight and ArrowLeft move the position by one unit; holding Shift moves it by five. Home jumps to the start, End to the end. Direction is reversed automatically when the control renders right-to-left. Every keyboard step that changes the value calls onValueChange and then onValueChanged. Up and Down are not consumed, so vertical focus navigation still works.
  • Steps accumulate. Keyboard steps are applied to the last value the component emitted, not to the last value prop it rendered, so quick repeated presses keep moving even before your state update lands. The component re-syncs to value whenever the prop changes and no drag is active.
  • Boundary presses. An arrow press at the start or the end is still consumed — focus does not jump away — but neither callback fires, because the value did not change.
  • Pointer. Pressing the control with the primary button focuses it, captures the pointer, and seeks immediately to the pressed position, mapped from the track’s width and clamped to its ends. Moving emits onValueChange continuously. Releasing, cancelling, or losing pointer capture emits onValueChanged exactly once with the last value. Secondary buttons are ignored, and a second pointer cannot start a competing drag.
  • Pre-empting the built-in handling. Because your handlers run first, calling preventDefault() inside your onKeyDown or onPointerDown stops the component from acting on that event.
  • Timestamps. Rendered only when durationSeconds is greater than zero. The elapsed label is derived from value and the duration; both are formatted as minutes and seconds, extended with hours for long media. The timestamp row is hidden from assistive technology because the same information is in the control’s value text.
  • Tooltip. With showTooltip, a tooltip tracks the handle while the control is focused or being dragged and is hidden while disabled. It shows the elapsed time when a duration is set and the rounded percentage otherwise. The tooltip renders through the floating portal root, so it escapes any clipped ancestor; see Floating portals.
  • Scrim. Unless hideScrim is set, a scrim is drawn behind the control and fades in during interaction so the track and timestamps stay legible over bright, busy media on the additive-light display.
  • Handle states. The handle ring and dot fade and scale on hover, focus, and press. Those transitions, and the scrim fade, are disabled automatically when the user prefers reduced motion.
  • Disabled.disabled blocks keyboard stepping and pointer seeking alike, removes the tab stop, keeps the tooltip hidden, switches the cursor to not-allowed, and dims the control with the disabled opacity token. The control stays in the accessibility tree, marked aria-disabled.
  • Sizing. The control fills the width available to it and has a fixed intrinsic height; the handle offset follows the track’s own width, so it adapts as the layout changes.

Accessibility

  • The component reports role="slider" with aria-orientation="horizontal", aria-valuemin of 0, aria-valuemax of 100, and aria-valuenow set to the clamped position.
  • aria-valuetext announces elapsed and total time when a duration is set, and the rounded percentage otherwise.
  • The default aria-label is generic. Provide one that names the media or the player.
  • While disabled, aria-disabled is set and the tab stop is removed, so the control stays announced but is skipped by sequential focus.
  • Right-to-left is handled from the computed direction of the element, so both the keyboard direction and the pointer mapping stay correct without extra props.
  • Use onValueChange for cheap live feedback such as a preview position, and onValueChanged for the expensive commit: the seek request, analytics, or persistence.
  • Set durationSeconds whenever you know it, so users get real times instead of a percentage.
  • Keep the component mounted across playback state changes so focus and motion are preserved.
  • Let the media’s own player state remain the source of truth, and feed it back through value once the seek is applied.

Avoid

  • Avoid adding your own arrow-key handling; the scrubber already owns Left, Right, Home, and End.
  • Avoid passing seconds as value; convert to a 0–100 position first.
  • Avoid treating onValueChange as the commit — it fires for every pointer move.
  • Avoid rendering a second tooltip or a duplicate elapsed-time label for the same value.
  • Use the component-owned tooltip and timestamp positions. Internal DOM structure is not a supported customization surface.
  • Prefer Scrubber over SliderBar when time semantics matter.