Scrub Timeline Player
A media scrubber with buffered range, hover timestamp and pointer-capture dragging.
- Category
- Components
- Added
- 2026-09-20
- Deps
- 1
- Updated
- 2026-09-20
Editorial Machined
viberdy · 2.0
Props
"use client";
import { useRef, useState } from "react";
function formatTime(seconds: number) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return m + ":" + String(s).padStart(2, "0");
}
export function ScrubTimelinePlayer({
height = 5,
duration,
value,
buffered = 0,
showHoverTime = true,
onChange,
}: {
height?: number;
/** Timestamp bubble above the pointer while hovering the track. */
showHoverTime?: boolean;
duration: number;
/** Current position, 0-100. */
value: number;
/** Buffered extent, 0-100. */
buffered?: number;
onChange: (next: number) => void;
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [scrubbing, setScrubbing] = useState(false);
const [hoverPct, setHoverPct] = useState<number | null>(null);
function pctFromEvent(clientX: number) {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return 0;
return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
}
return (
<div
ref={trackRef}
/*
* A real slider, not a div with a click handler: role, the three value
* attributes, tabIndex and arrow keys are what make this operable
* without a pointer and announced correctly. aria-valuetext carries the
* formatted timestamp, since "42" is meaningless read aloud.
*/
role="slider"
tabIndex={0}
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value)}
aria-valuetext={formatTime((value / 100) * duration)}
onKeyDown={(e) => {
if (e.key === "ArrowRight") onChange(Math.min(100, value + 2));
if (e.key === "ArrowLeft") onChange(Math.max(0, value - 2));
}}
/*
* POINTER events with setPointerCapture — not mousedown/mousemove.
* Capture routes every later pointer event to this element even when the
* pointer is outside its bounds, so the scrub survives dragging above or
* below the bar. The mouse-event version silently stops there, which is
* exactly what people do when scrubbing quickly.
*/
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
setScrubbing(true);
onChange(pctFromEvent(e.clientX));
}}
onPointerMove={(e) => {
setHoverPct(pctFromEvent(e.clientX));
if (scrubbing) onChange(pctFromEvent(e.clientX));
}}
onPointerUp={(e) => {
e.currentTarget.releasePointerCapture(e.pointerId);
setScrubbing(false);
}}
onPointerLeave={() => setHoverPct(null)}
className="group relative cursor-pointer py-3"
>
<div
className="relative w-full overflow-hidden rounded-full bg-white/15 transition-[height] duration-150"
style={{ height: scrubbing ? height + 3 : height }}
>
<div className="absolute inset-y-0 left-0 bg-white/20" style={{ width: buffered + "%" }} />
<div className="absolute inset-y-0 left-0 bg-blue-500" style={{ width: value + "%" }} />
</div>
<span
className="pointer-events-none absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow transition-transform duration-150 group-hover:scale-110"
style={{ left: value + "%" }}
/>
{showHoverTime && hoverPct !== null && (
<span
className="pointer-events-none absolute bottom-full -translate-x-1/2 rounded bg-neutral-800 px-1.5 py-0.5 font-mono text-[10px] tabular-nums text-white"
style={{ left: hoverPct + "%" }}
>
{formatTime((hoverPct / 100) * duration)}
</span>
)}
</div>
);
}
About this component
Nearly every hand-rolled scrubber is built on mousedown plus a mousemove listener, and nearly every one has the same bug: drag fast, stray a few pixels above the bar, and the scrub stops dead. Pointer events fix it in one call. `setPointerCapture` on pointerdown routes every subsequent pointer event to that element regardless of where the pointer actually is, so the drag survives leaving the track — and as a bonus it removes the window-level listeners and their cleanup entirely, along with the touch handling a mouse-only implementation has to bolt on. The second half is semantics. A track is a slider, not a div that happens to respond to clicks, so it carries `role="slider"`, the three value attributes, a tab stop and arrow-key seeking. The detail people miss is `aria-valuetext`: without it a screen reader announces the raw number, and "forty-two" tells nobody where they are in a track — the formatted timestamp belongs there. The buffered layer is a third bar behind the played one, which costs nothing and is the difference between a toy and something that looks like a real player.
Curator’s note
Built for viberdy 2.0. Pointer capture is the difference between a scrubber that works and one that works only while you stay inside the bar.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Image Comparison Slider
componentsA draggable before/after divider that reveals one image over another.
Usage Pricing Calculator
componentsA live estimator with real marginal-tier overage pricing and an annual discount toggle.
Sparkline Scrub Cell
componentsAn inline micro-chart you sweep to read exact values, with monotone interpolation that never invents a peak.