A horizontal version-history scrubber: drag the thumb along the rail to preview any version, snapping to the nearest tick.
npx shadcn@latest add @paragon/version-history-scrubber"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 the thumb grows while held) and snaps
* to the nearest version, previewing that version's metadata. The fill is a
* full-width bar scaled by transform so it glides in sync with the thumb —
* both retarget mid-flight on arrow-key steps and snap without animating on
* mount and container resizes. Left/Right arrows and Home/End drive it for
* keyboard users via a slider role; reduced motion (or `static`) snaps.
*/
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);
// True for the commit in which a (re)measure lands — geometry derived from
// a new rail width must snap into place, never animate.
const [resizeSnap, setResizeSnap] = React.useState(false);
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);
setResizeSnap(true);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
if (!resizeSnap) return;
const raf = requestAnimationFrame(() => setResizeSnap(false));
return () => cancelAnimationFrame(raf);
}, [resizeSnap]);
// Usable track spans the rail minus the 4px inset on each side.
const trackW = Math.max(0, railW - 8);
const animateSlide = !isStatic && !dragging && !resizeSnap;
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 gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{current.label}</div>
<div className="truncate text-xs text-muted-foreground">
{current.author} · <span className="tabular-nums">{current.time}</span>
</div>
</div>
<span className="shrink-0 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-orientation="horizontal"
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 after:absolute after:inset-x-0 after:-inset-y-1 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 — full-width bar scaled on the compositor so it
stays in lockstep with the thumb's slide. */}
<div
aria-hidden
className={cn(
"absolute left-1 h-0.5 origin-left rounded-full bg-primary",
animateSlide &&
"transition-transform duration-200 ease-out motion-reduce:transition-none",
)}
style={{ width: trackW, transform: `scaleX(${fraction})` }}
/>
{/* 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 ease-out",
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; it grows slightly while held. */}
<span
aria-hidden
className={cn(
"absolute top-1/2 left-0 size-4 rounded-full bg-primary shadow-border",
animateSlide &&
"transition-transform duration-200 ease-out motion-reduce:transition-none",
dragging && "scale-125",
)}
style={{
transform: `translate(${4 + fraction * trackW - 8}px, -50%)`,
}}
/>
</div>
</div>
);
}