"use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { cn } from "@/lib/utils";
export interface HoldProps {
label?: ReactNode;
confirmLabel?: ReactNode;
duration?: number;
indicator?: "ring" | "bar" | "fill";
threshold?: number;
rewindRate?: number;
strokeWidth?: number;
holdScale?: number;
commitScale?: number;
successHold?: number;
accentColor?: string;
trackColor?: string;
onCommit?: () => void;
reducedMotion?: boolean;
className?: string;
}
export default function Hold({
label = "Hold to confirm",
confirmLabel = "Confirmed",
duration = 1200,
indicator = "ring",
threshold = 0.92,
rewindRate = 2.4,
strokeWidth = 3,
holdScale = 0.96,
commitScale = 1.06,
successHold = 1.4,
accentColor = "#a855f7",
trackColor = "#2a2136",
onCommit,
reducedMotion = false,
className,
}: HoldProps) {
const [phase, setPhase] = useState<"idle" | "holding" | "committed">("idle");
const progress = useMotionValue(0);
const scale = useMotionValue(1);
const heldRef = useRef(false);
const commitTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const fillRef = useRef<{ stop: () => void } | null>(null);
const live = useRef({ duration, threshold, rewindRate, commitScale, successHold, reducedMotion, onCommit });
live.current = { duration, threshold, rewindRate, commitScale, successHold, reducedMotion, onCommit };
const clearTimers = useCallback(() => {
if (commitTimer.current !== null) {
clearTimeout(commitTimer.current);
commitTimer.current = null;
}
}, []);
const commit = useCallback(() => {
clearTimers();
fillRef.current?.stop();
setPhase("committed");
fillRef.current = animate(progress, 1, { duration: 0.12, ease: [0.16, 1, 0.3, 1] });
if (!live.current.reducedMotion) {
animate(scale, live.current.commitScale, { type: "spring", stiffness: 700, damping: 14 }).then(
() => animate(scale, 1, { type: "spring", stiffness: 320, damping: 24 }),
);
}
live.current.onCommit?.();
resetTimer.current = setTimeout(() => {
setPhase("idle");
progress.set(0);
scale.set(1);
}, live.current.successHold * 1000);
}, [clearTimers, progress, scale]);
const start = useCallback(() => {
if (heldRef.current || phase === "committed") return;
heldRef.current = true;
setPhase("holding");
clearTimers();
fillRef.current?.stop();
const from = progress.get();
const { duration: ms, threshold: at } = live.current;
fillRef.current = animate(progress, 1, { duration: ((1 - from) * ms) / 1000, ease: "linear" });
commitTimer.current = setTimeout(commit, Math.max(0, (at - from) * ms));
animate(scale, holdScale, { type: "spring", stiffness: 520, damping: 30 });
}, [clearTimers, commit, holdScale, phase, progress, scale]);
const release = useCallback(() => {
heldRef.current = false;
animate(scale, 1, { type: "spring", stiffness: 420, damping: 26 });
if (phase !== "holding") return;
clearTimers();
fillRef.current?.stop();
setPhase("idle");
const from = progress.get();
const { duration: ms, rewindRate: rate } = live.current;
fillRef.current = animate(progress, 0, {
duration: (from * ms) / rate / 1000,
ease: [0.22, 1, 0.36, 1],
});
}, [clearTimers, phase, progress, scale]);
useEffect(
() => () => {
if (commitTimer.current !== null) clearTimeout(commitTimer.current);
if (resetTimer.current !== null) clearTimeout(resetTimer.current);
fillRef.current?.stop();
},
[],
);
const dashOffset = useTransform(progress, (p) => 1 - p);
const glow = useTransform(progress, (p) => p * 0.55);
const committed = phase === "committed";
const ringRadius = 12 - strokeWidth / 2 - 1;
return (
<div className={cn("inline-flex flex-col items-stretch gap-2", className)}>
<motion.button
type="button"
style={{ scale }}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
start();
}}
onPointerUp={release}
onPointerCancel={release}
onLostPointerCapture={release}
onBlur={release}
onKeyDown={(e) => {
if (e.key !== " " && e.key !== "Enter") return;
e.preventDefault();
if (e.repeat) return;
start();
}}
onKeyUp={(e) => {
if (e.key !== " " && e.key !== "Enter") return;
release();
}}
onContextMenu={(e) => e.preventDefault()}
className={cn(
"relative isolate flex items-center gap-3 overflow-hidden rounded-full",
"border border-hairline bg-raised px-5 py-2.5 select-none",
"font-display text-[11px] tracking-[0.22em] text-ink uppercase",
"transition-colors duration-200 outline-none",
"focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface",
)}
>
{indicator === "fill" ? (
<motion.span
aria-hidden
className="absolute inset-0 -z-10 origin-left"
style={{ scaleX: progress, backgroundColor: accentColor, opacity: 0.22 }}
/>
) : null}
{reducedMotion ? null : (
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10"
style={{ opacity: glow, boxShadow: `inset 0 0 24px ${accentColor}` }}
/>
)}
<span className="relative grid size-6 shrink-0 place-items-center">
<svg viewBox="0 0 24 24" className="size-6 -rotate-90" aria-hidden>
<circle
cx="12"
cy="12"
r={ringRadius}
fill="none"
stroke={indicator === "ring" ? trackColor : "transparent"}
strokeOpacity={0.45}
strokeWidth={strokeWidth}
/>
{indicator === "ring" ? (
<motion.circle
cx="12"
cy="12"
r={ringRadius}
fill="none"
stroke={accentColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
pathLength={1}
strokeDasharray="1 1"
style={{ strokeDashoffset: dashOffset }}
/>
) : null}
</svg>
<svg
viewBox="0 0 24 24"
className={cn(
"absolute size-3.5 transition-opacity duration-200",
committed ? "opacity-100" : "opacity-0",
)}
aria-hidden
>
<path
d="M4 12.5 9.5 18 20 6"
fill="none"
stroke={accentColor}
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span className="relative">{committed ? confirmLabel : label}</span>
</motion.button>
<span
aria-hidden
className="relative w-full overflow-hidden rounded-full"
style={{ height: indicator === "bar" ? strokeWidth : 0 }}
>
<span
className="absolute inset-0"
style={{
backgroundColor: indicator === "bar" ? trackColor : "transparent",
opacity: 0.45,
}}
/>
<motion.span
className="absolute inset-0 origin-left rounded-full"
style={{ scaleX: progress, backgroundColor: accentColor }}
/>
</span>
<span className="sr-only" role="status" aria-live="polite">
{committed ? "Confirmed" : ""}
</span>
</div>
);
}