"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls } from "motion/react";
type Phase = "idle" | "holding" | "confirmed";
export interface HoldToConfirmProps
extends Omit<
React.ComponentPropsWithoutRef<"button">,
"onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd" | "onClick"
> {
/** Label shown before confirmation (also the accessible name). */
children?: React.ReactNode;
/** Label shown (and announced) once confirmed. */
confirmedLabel?: string;
/** How long the press must be held, in ms. */
holdDuration?: number;
/** How long the confirmed state stays before resetting, in ms. */
resetDelay?: number;
/** Progress and confirmation colour. */
accent?: string;
/** Label / icon colour on top of the accent once confirmed. */
inkColor?: string;
/** Pill with label, or a round icon button with a caption. */
variant?: "pill" | "circle";
/** Instruction for assistive technology (linked via aria-describedby). */
hint?: string;
/** Icon in the centre of the ring. Defaults to a trash glyph. */
icon?: React.ReactNode;
/** Called once when the hold completes. */
onConfirm?: () => void;
/** Called when a hold starts. */
onHoldStart?: () => void;
/** Called when a hold is released before completing. */
onCancel?: () => void;
}
const RING_R = 13;
function TrashGlyph() {
return (
<svg viewBox="0 0 16 16" fill="none" className="size-3.5" aria-hidden="true">
<path d="M2.5 4.5h11M6 4.5V3h4v1.5M4 4.5l.6 8.1a1 1 0 0 0 1 .9h4.8a1 1 0 0 0 1-.9l.6-8.1" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/**
* A destructive-action button that only fires after a sustained press. Progress is drawn
* as an SVG ring; releasing early rewinds it. Works with pointer and Space/Enter.
*/
export const HoldToConfirm = React.forwardRef<HTMLButtonElement, HoldToConfirmProps>(function HoldToConfirm(
{
children = "Hold to delete",
confirmedLabel = "Deleted",
holdDuration = 1500,
resetDelay = 2000,
accent = "#FF7448",
inkColor = "#0B0D0F",
variant = "pill",
hint = "Press and hold to confirm. Release to cancel.",
icon,
onConfirm,
onHoldStart,
onCancel,
disabled,
className = "",
style,
onPointerDown,
onPointerUp,
onPointerCancel,
onKeyDown,
onKeyUp,
onBlur,
...props
},
ref,
) {
const reduceMotion = useReducedMotion();
const hintId = React.useId();
const [phase, setPhase] = React.useState<Phase>("idle");
const [announcement, setAnnouncement] = React.useState("");
const progress = useMotionValue(0);
const fillWidth = useTransform(progress, (p) => `${p * 100}%`);
// Hide the round line cap when there is no progress, otherwise a dot remains visible.
const ringOpacity = useTransform(progress, (p) => (p > 0.001 ? 1 : 0));
const controls = React.useRef<AnimationPlaybackControls | null>(null);
const holding = React.useRef(false);
const resetTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
React.useEffect(
() => () => {
clearTimeout(resetTimer.current);
controls.current?.stop();
},
[],
);
const confirm = () => {
holding.current = false;
setPhase("confirmed");
setAnnouncement(confirmedLabel);
onConfirm?.();
resetTimer.current = setTimeout(() => {
setPhase("idle");
setAnnouncement("");
controls.current = animate(progress, 0, { duration: reduceMotion ? 0 : 0.45, ease: [0.22, 1, 0.36, 1] });
}, Math.max(resetDelay, 0));
};
const start = () => {
if (disabled || phase === "confirmed" || holding.current) return;
holding.current = true;
controls.current?.stop();
setPhase("holding");
setAnnouncement("Holding…");
onHoldStart?.();
// Resume from wherever a rewind left off, so a quick re-press is not penalised.
const remaining = (1 - progress.get()) * Math.max(holdDuration, 0);
controls.current = animate(progress, 1, { duration: remaining / 1000, ease: "linear", onComplete: confirm });
};
const cancel = () => {
if (!holding.current) return;
holding.current = false;
controls.current?.stop();
setPhase("idle");
setAnnouncement("Released — not confirmed.");
onCancel?.();
const p = progress.get();
controls.current = animate(progress, 0, {
duration: reduceMotion ? 0 : 0.2 + 0.35 * p,
ease: [0.22, 1, 0.36, 1],
});
};
const isActivationKey = (key: string) => key === " " || key === "Enter";
const confirmed = phase === "confirmed";
const pressed = phase === "holding" && !reduceMotion;
const ring = (size: string, strokeWidth: number) => (
<span aria-hidden="true" className={`relative grid shrink-0 place-items-center ${size}`}>
<svg viewBox="0 0 32 32" className="absolute inset-0 size-full -rotate-90" fill="none">
<circle cx="16" cy="16" r={RING_R} stroke="currentColor" strokeOpacity={confirmed ? 0 : 0.16} strokeWidth={strokeWidth} />
<motion.circle
cx="16"
cy="16"
r={RING_R}
stroke={confirmed ? "var(--hc-ink)" : "var(--hc-accent)"}
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ pathLength: progress, opacity: ringOpacity }}
/>
</svg>
{confirmed ? (
<svg viewBox="0 0 16 16" fill="none" className="size-3.5">
<motion.path
d="M3.5 8.5l3 3 6-7"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
/>
</svg>
) : (
(icon ?? <TrashGlyph />)
)}
</span>
);
const surface = confirmed
? "border-transparent bg-[var(--hc-accent)] text-[var(--hc-ink)]"
: "border-zinc-900/15 bg-white/60 text-zinc-900 dark:border-white/15 dark:bg-white/[0.03] dark:text-zinc-100";
const shape =
variant === "circle" ? "size-16 justify-center rounded-full" : "h-12 gap-3 rounded-full pl-2 pr-5 text-sm font-medium tracking-tight";
return (
<span className="inline-flex shrink-0 flex-col items-center gap-2.5">
<motion.button
ref={ref}
type="button"
disabled={disabled}
aria-describedby={hintId}
aria-disabled={confirmed || undefined}
data-state={phase}
animate={{ scale: pressed ? 0.97 : 1 }}
transition={{ type: "spring", stiffness: 400, damping: 28 }}
onPointerDown={(event) => {
onPointerDown?.(event);
if (event.button !== 0) return;
// Capture keeps the hold alive if the finger drifts slightly off the button.
event.currentTarget.setPointerCapture(event.pointerId);
start();
}}
onPointerUp={(event) => {
onPointerUp?.(event);
cancel();
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
cancel();
}}
onLostPointerCapture={cancel}
onKeyDown={(event) => {
onKeyDown?.(event);
if (!isActivationKey(event.key)) return;
event.preventDefault();
if (!event.repeat) start();
}}
onKeyUp={(event) => {
onKeyUp?.(event);
if (!isActivationKey(event.key)) return;
event.preventDefault();
cancel();
}}
onBlur={(event) => {
onBlur?.(event);
cancel();
}}
onContextMenu={(event) => event.preventDefault()}
className={`relative isolate inline-flex shrink-0 touch-none select-none items-center overflow-hidden whitespace-nowrap border transition-[background-color,color,border-color] duration-300 [-webkit-touch-callout:none] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--hc-accent)] disabled:cursor-not-allowed disabled:opacity-50 ${shape} ${surface} ${className}`}
style={{ ["--hc-accent" as string]: accent, ["--hc-ink" as string]: inkColor, ...style }}
{...props}
>
{variant === "pill" && !confirmed && (
// Soft wash that tracks progress behind the label.
<motion.span
aria-hidden="true"
className="absolute inset-y-0 left-0 -z-10 bg-[color-mix(in_oklab,var(--hc-accent)_16%,transparent)]"
style={{ width: fillWidth }}
/>
)}
{variant === "pill" ? ring("size-8", 2.25) : ring("size-full", 1.75)}
{variant === "pill" ? (
<span className="relative">{confirmed ? confirmedLabel : children}</span>
) : (
<span className="sr-only">{confirmed ? confirmedLabel : children}</span>
)}
</motion.button>
{variant === "circle" && (
<span aria-hidden="true" className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{confirmed ? confirmedLabel : children}
</span>
)}
<span id={hintId} className="sr-only">
{hint}
</span>
<span role="status" aria-live="polite" className="sr-only">
{announcement}
</span>
</span>
);
});