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