Navigation

Version History Scrubber

A horizontal version-history scrubber: drag the thumb along the rail to preview any version, snapping to the nearest tick.

Install

npx shadcn@latest add @paragon/version-history-scrubber

version-history-scrubber.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface Version {
  id: string;
  label: string;
  /** Pre-formatted timestamp for determinism. */
  time: string;
  author: string;
}

export interface VersionHistoryScrubberProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  versions: Version[];
  /** Controlled index of the previewed version. */
  value?: number;
  defaultValue?: number;
  onValueChange?: (index: number) => void;
  /** Renders the thumb without a slide transition. */
  static?: boolean;
}

/**
 * A horizontal version-history scrubber. Version ticks sit along a rail; a thumb
 * slides between them as you drag (pointer-captured, so a drag that leaves the
 * rail keeps tracking) and snaps to the nearest version on release, previewing
 * that version's metadata. Left/Right arrows step versions for keyboard users
 * via a slider role. The thumb slide is a transform transition that retargets;
 * reduced motion (or `static`) snaps it in place.
 */
export function VersionHistoryScrubber({
  versions,
  value,
  defaultValue = 0,
  onValueChange,
  static: isStatic = false,
  className,
  ...props
}: VersionHistoryScrubberProps) {
  const railRef = React.useRef<HTMLDivElement>(null);
  const [internal, setInternal] = React.useState(defaultValue);
  const [dragging, setDragging] = React.useState(false);
  const [railW, setRailW] = React.useState(0);
  const index = value ?? internal;
  const last = versions.length - 1;

  React.useLayoutEffect(() => {
    const el = railRef.current;
    if (!el) return;
    const observer = new ResizeObserver(([entry]) =>
      setRailW(entry.contentRect.width),
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  // Usable track spans the rail minus the 4px inset on each side.
  const trackW = Math.max(0, railW - 8);

  const set = (next: number) => {
    const clamped = Math.min(last, Math.max(0, next));
    if (value === undefined) setInternal(clamped);
    onValueChange?.(clamped);
  };

  const indexFromClientX = (clientX: number) => {
    const rect = railRef.current?.getBoundingClientRect();
    if (!rect || last <= 0) return 0;
    const fraction = (clientX - rect.left) / rect.width;
    return Math.round(Math.min(1, Math.max(0, fraction)) * last);
  };

  const current = versions[index];
  const fraction = last > 0 ? index / last : 0;

  return (
    <div
      data-slot="version-history-scrubber"
      className={cn(
        "flex w-full max-w-md flex-col gap-4 rounded-xl bg-card p-5 shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-baseline justify-between">
        <div>
          <div className="text-sm font-semibold">{current.label}</div>
          <div className="text-xs text-muted-foreground">
            {current.author} · <span className="tabular-nums">{current.time}</span>
          </div>
        </div>
        <span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium tabular-nums text-muted-foreground">
          {index + 1} / {versions.length}
        </span>
      </div>

      <div
        ref={railRef}
        role="slider"
        aria-label="Version history"
        aria-valuemin={0}
        aria-valuemax={last}
        aria-valuenow={index}
        aria-valuetext={`${current.label}, ${current.time}`}
        tabIndex={0}
        onPointerDown={(e) => {
          e.currentTarget.setPointerCapture(e.pointerId);
          setDragging(true);
          set(indexFromClientX(e.clientX));
        }}
        onPointerMove={(e) => {
          if (e.currentTarget.hasPointerCapture(e.pointerId)) {
            set(indexFromClientX(e.clientX));
          }
        }}
        onPointerUp={() => setDragging(false)}
        onPointerCancel={() => setDragging(false)}
        onKeyDown={(e) => {
          if (e.key === "ArrowRight") set(index + 1);
          if (e.key === "ArrowLeft") set(index - 1);
          if (e.key === "Home") set(0);
          if (e.key === "End") set(last);
        }}
        className="relative flex h-8 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-card"
      >
        {/* Rail */}
        <div className="absolute inset-x-1 h-0.5 rounded-full bg-border" />
        {/* Filled portion */}
        <div
          className="absolute left-1 h-0.5 rounded-full bg-primary"
          style={{ width: fraction * trackW }}
        />
        {/* Ticks */}
        {versions.map((v, i) => (
          <span
            key={v.id}
            aria-hidden
            className={cn(
              "absolute top-1/2 size-1.5 -translate-y-1/2 rounded-full transition-colors duration-150",
              i <= index ? "bg-primary" : "bg-border",
            )}
            style={{
              transform: `translateX(${4 + (last > 0 ? i / last : 0) * trackW - 3}px)`,
            }}
          />
        ))}
        {/* Thumb — positioned by transform so the slide animates on the
            compositor and never touches layout. */}
        <span
          aria-hidden
          className={cn(
            "absolute top-1/2 left-0 size-4 rounded-full bg-primary shadow-border",
            !isStatic && "transition-transform duration-200 ease-out",
            dragging && "transition-none",
          )}
          style={{
            transform: `translate(${4 + fraction * trackW - 8}px, -50%)`,
          }}
        />
      </div>
    </div>
  );
}