"use client";
import { useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useSpring, useTransform } from "motion/react";
import { cn } from "@/lib/utils";
export type RewindOrientation = "horizontal" | "vertical";
export type RewindTicks = "sparse" | "even" | "all";
export interface RewindNode {
id: string;
label: string;
}
export interface RewindBranch {
at: number;
nodes: RewindNode[];
}
interface RewindProps {
nodes?: RewindNode[];
branches?: RewindBranch[];
index?: number;
onScrub?: (index: number) => void;
onEnterBranch?: (branch: number) => void;
orientation?: RewindOrientation;
scrubSnap?: boolean;
branching?: boolean;
branchDepth?: number;
stiffness?: number;
damping?: number;
tickDensity?: RewindTicks;
showLabels?: boolean;
railHeight?: number;
accentColor?: string;
branchColor?: string;
reducedMotion?: boolean;
className?: string;
}
const EMPTY: RewindNode[] = [];
const NO_BRANCHES: RewindBranch[] = [];
export default function Rewind({
nodes = EMPTY,
branches = NO_BRANCHES,
index = 0,
onScrub,
onEnterBranch,
orientation = "horizontal",
scrubSnap = true,
branching = true,
branchDepth = 3,
stiffness = 420,
damping = 34,
tickDensity = "even",
showLabels = true,
railHeight = 4,
accentColor = "#a855f7",
branchColor = "#f0a830",
reducedMotion = false,
className,
}: RewindProps) {
const railRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(false);
const vertical = orientation === "vertical";
const last = Math.max(0, nodes.length - 1);
const clamped = Math.max(0, Math.min(last, index));
const raw = useMotionValue(last === 0 ? 0 : clamped / last);
const sprung = useSpring(raw, { stiffness, damping, mass: 0.6 });
const head = reducedMotion ? raw : sprung;
const offset = useTransform(head, (v) => `${(v * 100).toFixed(3)}%`);
useEffect(() => {
if (dragging) return;
raw.set(last === 0 ? 0 : clamped / last);
}, [clamped, last, dragging, raw]);
const fractionFrom = (event: React.PointerEvent<HTMLDivElement>): number => {
const rail = railRef.current;
if (!rail) return 0;
const box = rail.getBoundingClientRect();
const span = vertical ? box.height : box.width;
if (span <= 0) return 0;
const along = vertical ? event.clientY - box.top : event.clientX - box.left;
return Math.max(0, Math.min(1, along / span));
};
const apply = (fraction: number) => {
const next = Math.max(0, Math.min(last, Math.round(fraction * last)));
raw.set(scrubSnap ? (last === 0 ? 0 : next / last) : fraction);
if (next !== clamped) onScrub?.(next);
};
const onDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (nodes.length < 2) return;
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
apply(fractionFrom(event));
};
const onMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragging) apply(fractionFrom(event));
};
const onUp = (event: React.PointerEvent<HTMLDivElement>) => {
setDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
raw.set(last === 0 ? 0 : clamped / last);
};
const onKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
const back = vertical ? "ArrowUp" : "ArrowLeft";
const forward = vertical ? "ArrowDown" : "ArrowRight";
const go = (to: number) => {
event.preventDefault();
onScrub?.(Math.max(0, Math.min(last, to)));
};
if (event.key === back) go(clamped - 1);
else if (event.key === forward) go(clamped + 1);
else if (event.key === "Home") go(0);
else if (event.key === "End") go(last);
};
const stride =
tickDensity === "all"
? 1
: Math.max(1, Math.ceil(nodes.length / (tickDensity === "even" ? 24 : 10)));
const shown = branching ? branches.slice(0, Math.max(1, branchDepth)) : [];
const pct = (i: number) => (last === 0 ? 0 : (i / last) * 100);
const branchSpan = (branch: RewindBranch) =>
Math.max(5, Math.min(100 - pct(branch.at), (branch.nodes.length / Math.max(1, last)) * 100));
return (
<div
className={cn(
"flex select-none",
vertical ? "h-full flex-row gap-5" : "w-full flex-col gap-3",
className,
)}
>
<div className={cn("relative", vertical ? "h-full" : "w-full")}>
<div
ref={railRef}
role="slider"
tabIndex={0}
aria-label="History position"
aria-valuemin={0}
aria-valuemax={last}
aria-valuenow={clamped}
aria-valuetext={nodes[clamped]?.label ?? "empty"}
aria-orientation={orientation}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
className={cn(
"relative cursor-pointer touch-none rounded-full bg-raised outline-none focus-visible:ring-2 focus-visible:ring-accent/60",
vertical ? "mx-auto h-full" : "w-full",
)}
style={vertical ? { width: railHeight } : { height: railHeight }}
>
{vertical ? (
<motion.span
aria-hidden
className="absolute top-0 left-0 block w-full rounded-full"
style={{ backgroundColor: accentColor, height: offset }}
/>
) : (
<motion.span
aria-hidden
className="absolute top-0 left-0 block h-full rounded-full"
style={{ backgroundColor: accentColor, width: offset }}
/>
)}
{nodes.map((node, i) =>
i % stride === 0 || i === last ? (
<span
key={node.id}
aria-hidden
className="absolute block rounded-full bg-ink-mute"
style={{
width: 3,
height: 3,
opacity: i <= clamped ? 0 : 0.8,
transform: "translate(-50%, -50%)",
...(vertical
? { top: `${pct(i)}%`, left: "50%" }
: { left: `${pct(i)}%`, top: "50%" }),
}}
/>
) : null,
)}
<motion.span
aria-hidden
className="absolute block rounded-full border-2 bg-surface shadow-lg"
style={{
width: 14,
height: 14,
borderColor: accentColor,
transform: "translate(-50%, -50%)",
...(vertical ? { top: offset, left: "50%" } : { left: offset, top: "50%" }),
}}
/>
</div>
{shown.map((branch, b) => (
<button
key={`${branch.at}-${b}-${branch.nodes.length}`}
type="button"
onClick={() => onEnterBranch?.(b)}
title={`Abandoned future · ${branch.nodes.length} step${branch.nodes.length === 1 ? "" : "s"}`}
aria-label={`Return to an abandoned future of ${branch.nodes.length} steps`}
className="absolute rounded-full opacity-50 transition-opacity hover:opacity-100"
style={{
backgroundColor: branchColor,
...(vertical
? {
width: railHeight,
left: `calc(50% + ${(b + 1) * 11}px)`,
top: `${pct(branch.at)}%`,
height: `${branchSpan(branch)}%`,
}
: {
height: railHeight,
top: `calc(50% + ${(b + 1) * 10}px)`,
left: `${pct(branch.at)}%`,
width: `${branchSpan(branch)}%`,
}),
}}
/>
))}
</div>
{showLabels ? (
<div
className={cn(
"flex gap-2 font-display text-[10px] tracking-[0.18em] uppercase",
vertical ? "flex-col" : "items-baseline justify-between",
shown.length > 0 && !vertical ? "pt-3" : "",
)}
>
<span className="text-ink-dim">{nodes[clamped]?.label ?? "—"}</span>
<span className="tabular-nums text-ink-mute">
{nodes.length === 0 ? "0 / 0" : `${clamped + 1} / ${nodes.length}`}
{clamped < last ? " · in the past" : ""}
</span>
</div>
) : null}
</div>
);
}