Media

Video Scrubber

A video timeline scrubber with a cursor-tracking hover preview, buffered range, chapter ticks, and keyboard seeking.

Install

npx shadcn@latest add @paragon/video-scrubber

video-scrubber.tsx

"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>
  );
}