A video timeline scrubber with a cursor-tracking hover preview, buffered range, chapter ticks, and keyboard seeking.
npx shadcn@latest add @paragon/video-scrubber"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface VideoChapter {
/** Start time in seconds. */
at: number;
label: string;
}
export interface VideoScrubberProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
/** Total duration in seconds. */
duration?: number;
/** Current play head in seconds. */
current?: number;
/** Fraction buffered ahead, 0–1. */
buffered?: number;
/** Chapter markers along the track. */
chapters?: VideoChapter[];
/** Called when the user seeks (fraction 0–1). */
onSeek?: (fraction: number) => void;
/** Renders a scene label instead of an image in the preview. */
previewLabel?: (fraction: number) => string;
}
function formatTime(seconds: number) {
const s = Math.max(0, Math.floor(seconds));
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${r.toString().padStart(2, "0")}`;
}
/**
* A video timeline scrubber. The played range fills to the play head over a
* separately tinted buffered range; hovering the track lifts a preview card
* that tracks the cursor and shows the frame's timecode, clamped to stay
* inside the track. Seeking is pointer-captured and keyboard-arrow driven via a
* slider role. The hover preview is gated to fine pointers so touch never
* strands a floating card.
*/
export function VideoScrubber({
duration = 213,
current = 72,
buffered = 0.62,
chapters = [],
onSeek,
previewLabel,
className,
...props
}: VideoScrubberProps) {
const trackRef = React.useRef<HTMLDivElement>(null);
const [hoverX, setHoverX] = React.useState<number | null>(null);
const [width, setWidth] = React.useState(0);
React.useLayoutEffect(() => {
const el = trackRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) =>
setWidth(entry.contentRect.width),
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const progress = Math.min(1, Math.max(0, current / duration));
const fractionFromClientX = (clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return 0;
return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
};
const hoverFraction = hoverX != null && width > 0 ? hoverX / width : 0;
const previewW = 120;
const clampedX =
hoverX != null
? Math.min(Math.max(hoverX, previewW / 2), width - previewW / 2)
: 0;
return (
<div
data-slot="video-scrubber"
className={cn("w-full max-w-lg", className)}
{...props}
>
<div className="relative">
{/* Hover preview */}
{hoverX != null && (
<div
aria-hidden
className="pointer-events-none absolute bottom-6 z-10 -translate-x-1/2"
style={{ left: clampedX, width: previewW }}
>
<div className="overflow-hidden rounded-lg shadow-overlay">
<div className="flex aspect-video items-center justify-center bg-gradient-to-br from-muted to-accent text-xs font-medium text-muted-foreground">
{previewLabel
? previewLabel(hoverFraction)
: formatTime(hoverFraction * duration)}
</div>
</div>
<div className="mt-1 text-center font-mono text-xs tabular-nums text-muted-foreground">
{formatTime(hoverFraction * duration)}
</div>
</div>
)}
<div
ref={trackRef}
role="slider"
aria-label="Seek video"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)}
aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
tabIndex={0}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
onSeek?.(fractionFromClientX(e.clientX));
}}
onPointerMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
// Preview only for fine pointers; touch never strands a card.
if (e.pointerType === "mouse") setHoverX(e.clientX - rect.left);
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
onSeek?.(fractionFromClientX(e.clientX));
}
}}
onPointerLeave={() => setHoverX(null)}
onKeyDown={(e) => {
if (e.key === "ArrowRight") onSeek?.(Math.min(1, progress + 0.02));
if (e.key === "ArrowLeft") onSeek?.(Math.max(0, progress - 0.02));
}}
className="group relative flex h-6 cursor-pointer touch-none items-center outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<div className="relative h-1 w-full rounded-full bg-muted-foreground/20 transition-[height] duration-150 group-hover:h-1.5 group-focus-visible:h-1.5">
{/* Buffered */}
<div
className="absolute inset-y-0 left-0 rounded-full bg-muted-foreground/30"
style={{ width: `${buffered * 100}%` }}
/>
{/* Played */}
<div
className="absolute inset-y-0 left-0 rounded-full bg-primary"
style={{ width: `${progress * 100}%` }}
/>
{/* Chapter ticks */}
{chapters.map((c) => (
<span
key={c.at}
aria-hidden
title={c.label}
className="absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-background/70"
style={{ left: `${(c.at / duration) * 100}%` }}
/>
))}
{/* Play head */}
<span
aria-hidden
className="absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary opacity-0 shadow-border transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
style={{ left: `${progress * 100}%` }}
/>
</div>
</div>
</div>
<div className="mt-1.5 flex items-center justify-between font-mono text-xs tabular-nums text-muted-foreground">
<span>{formatTime(current)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
);
}