A hold-to-confirm button that fills with a clip-path wipe while pressed and snaps back in ~200ms if released early.
npx shadcn@latest add @paragon/confirm-hold-button"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type HoldState = "idle" | "holding" | "confirmed";
export interface ConfirmHoldButtonProps extends React.ComponentProps<"button"> {
/**
* How long the button must be held before confirming, in ms.
* Deliberately allowed past the 300ms UI ceiling — the delay is the feature.
*/
holdDuration?: number;
/** How long the filled "confirmed" state lingers before resetting, in ms. */
confirmedDuration?: number;
/** Called once, when the hold completes. */
onConfirm?: () => void;
/** Color of the confirmation fill. */
variant?: "default" | "destructive";
/** Disables press-scale feedback where the motion would distract. */
static?: boolean;
}
/**
* A hold-to-confirm button. While pressed, a fill layer wipes across the
* button via a `clip-path` transition timed to `holdDuration`; releasing
* early retargets the same transition into a ~200ms snap-back (CSS
* transitions retarget from the current state — never keyframes here).
* Keyboard holds work via Space/Enter keydown→keyup. Under
* `prefers-reduced-motion` the wipe is replaced by an opacity fade over the
* same duration, so progress feedback survives without movement.
*/
export function ConfirmHoldButton({
holdDuration = 1200,
confirmedDuration = 800,
onConfirm,
variant = "default",
static: isStatic = false,
className,
disabled,
children = "Hold to confirm",
onPointerDown,
onPointerUp,
onPointerCancel,
onKeyDown,
onKeyUp,
onBlur,
onContextMenu,
...props
}: ConfirmHoldButtonProps) {
const [state, setState] = React.useState<HoldState>("idle");
const confirmTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const resetTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
return () => {
if (confirmTimer.current) clearTimeout(confirmTimer.current);
if (resetTimer.current) clearTimeout(resetTimer.current);
};
}, []);
const start = () => {
if (disabled || state !== "idle") return;
setState("holding");
confirmTimer.current = setTimeout(() => {
confirmTimer.current = null;
setState("confirmed");
onConfirm?.();
resetTimer.current = setTimeout(() => {
resetTimer.current = null;
setState("idle");
}, confirmedDuration);
}, holdDuration);
};
const cancel = () => {
if (confirmTimer.current) {
clearTimeout(confirmTimer.current);
confirmTimer.current = null;
}
setState((s) => (s === "holding" ? "idle" : s));
};
return (
<button
type="button"
data-slot="confirm-hold-button"
data-state={state}
disabled={disabled}
onPointerDown={(e) => {
onPointerDown?.(e);
if (e.defaultPrevented) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
// Capture so releasing outside the button still ends the hold.
e.currentTarget.setPointerCapture(e.pointerId);
start();
}}
onPointerUp={(e) => {
onPointerUp?.(e);
cancel();
}}
onPointerCancel={(e) => {
onPointerCancel?.(e);
cancel();
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key === "Escape") cancel();
if (e.key === " " || e.key === "Enter") {
// Suppress the native click/scroll so the hold timer is the only
// path to confirmation.
e.preventDefault();
if (!e.repeat) start();
}
}}
onKeyUp={(e) => {
onKeyUp?.(e);
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
cancel();
}
}}
onBlur={(e) => {
onBlur?.(e);
cancel();
}}
onContextMenu={(e) => {
onContextMenu?.(e);
// Long-press on touch would otherwise open the context menu mid-hold.
if (state === "holding") e.preventDefault();
}}
className={cn(
"relative inline-flex h-10 touch-none items-center justify-center gap-2 rounded-lg bg-card px-5 text-sm font-medium whitespace-nowrap select-none shadow-border dark:bg-secondary/30",
"transition-[scale,box-shadow] duration-150 ease-out hover:shadow-border-hover",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
!isStatic && "data-[state=holding]:scale-[0.97]",
className,
)}
{...props}
>
<span className="flex items-center gap-2">{children}</span>
<span
aria-hidden
style={{ "--chb-hold": `${holdDuration}ms` } as React.CSSProperties}
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-center gap-2 rounded-[inherit]",
variant === "destructive"
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground",
// Reduced motion: fade instead of wipe — progress stays legible.
"transition-[clip-path] motion-reduce:transition-[opacity]",
state === "idle"
? "duration-200 ease-out [clip-path:inset(0_100%_0_0)] motion-reduce:opacity-0"
: "ease-linear [clip-path:inset(0_0_0_0)] [transition-duration:var(--chb-hold)]",
)}
>
{children}
</span>
</button>
);
}