Overview

Destructive actions that shouldn't be a single slip tap. Hold fills left-to-right on a linear clock; release early and the fill snaps back in 200ms. Completing the hold fires onConfirm. Under prefers-reduced-motion, the control becomes a two-step click confirm instead.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/hold-confirm.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/hold-confirm.tsx
tsx
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";import {  animate,  motion,  useMotionValue,  useReducedMotion,  useTransform,} from "motion/react";import { cn } from "@/lib/utils";
export type HoldConfirmProps = {  onConfirm: () => void;  holdMs?: number;  label?: string;  confirmLabel?: string;  armedLabel?: string;  variant?: "destructive" | "default";  className?: string;  disabled?: boolean;  /** Optional leading icon. Defaults to a trash glyph for the destructive variant, none otherwise. */  icon?: React.ReactNode;  /** How long the "armed" state lasts before auto-resetting (reduced-motion fallback). */  armWindowMs?: number;};
/** Strong ease-out — release / success must feel instant */const EASE_OUT = [0.16, 1, 0.3, 1] as const;/** Hold is deliberate: linear clock. Snap-back is a fast spring. */const SNAP = { type: "spring" as const, stiffness: 520, damping: 38, mass: 0.7 };
export function HoldConfirm({  onConfirm,  holdMs = 1600,  label = "Delete project",  confirmLabel = "Hold to confirm",  armedLabel = "Press again to confirm",  variant = "destructive",  className,  disabled,  icon,  armWindowMs = 3000,}: HoldConfirmProps) {  const reduce = useReducedMotion();  const holding = useRef(false);  const confirmed = useRef(false);  const animRef = useRef<ReturnType<typeof animate> | null>(null);  const armTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);  const doneTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
  const progress = useMotionValue(0);  // clip-path fill — full-size element, only visually clipped, so the sheen child  // keeps normal geometry instead of getting squashed by a scaleX transform.  const clipPath = useTransform(    progress,    [0, 1],    ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],  );  // Label ink flips as the fill crosses the midline  const inkReveal = useTransform(progress, [0.42, 0.58], [0, 1]);  const inkIdle = useTransform(progress, [0.42, 0.58], [1, 0]);  // Soft blur bridges the crossfade so two labels don't stack as distinct objects  const labelBlur = useTransform(progress, [0.4, 0.5, 0.6], [0, 2, 0]);  const labelFilter = useTransform(labelBlur, (b) => `blur(${b}px)`);  // Progress % fades in only while holding  const pctOpacity = useTransform(progress, [0.02, 0.12, 0.92, 1], [0, 1, 1, 0]);  // Subtle press scale driven by progress (settles at ~0.97 while held)  const pressScale = useTransform(progress, [0, 0.08, 1], [1, 0.97, 0.97]);  // Soft sheen on the fill's leading edge  const edgeLeft = useTransform(progress, (v) => `calc(${v * 100}% - 1.75rem)`);  const edgeOpacity = useTransform(    progress,    [0, 0.04, 0.96, 1],    [0, 0.85, 0.85, 0],  );  // Ambient "charging" glow — a drop-shadow (not box-shadow) so it isn't clipped  // by the button's own overflow-hidden, and can't fight the idle box-shadow classes.  const glowFilter = useTransform(progress, (v) => {    const isDestructive = variant === "destructive";    const color = isDestructive ? "var(--color-red-500)" : "var(--color-neutral-200)";    return `drop-shadow(0 0 ${(v * 5).toFixed(1)}px rgba(${color},${(v * 0.3).toFixed(2)}))`;  });
  const [pct, setPct] = useState(0);  const [armed, setArmed] = useState(false);  const [doneFlash, setDoneFlash] = useState(false);  const [announce, setAnnounce] = useState("");  const [isHolding, setIsHolding] = useState(false);
  useEffect(() => {    return progress.on("change", (v) =>      setPct(Math.round(Math.min(1, Math.max(0, v)) * 100)),    );  }, [progress]);
  const clearArmTimer = useCallback(() => {    if (armTimeout.current) {      clearTimeout(armTimeout.current);      armTimeout.current = null;    }  }, []);
  const disarm = useCallback(() => {    clearArmTimer();    setArmed(false);  }, [clearArmTimer]);
  const arm = useCallback(() => {    setArmed(true);    clearArmTimer();    armTimeout.current = setTimeout(disarm, armWindowMs);  }, [armWindowMs, clearArmTimer, disarm]);
  const snapBack = useCallback(() => {    animRef.current?.stop();    // Asymmetric: release is snappy (hold was slow + linear)    animRef.current = animate(progress, 0, SNAP);  }, [progress]);
  const finish = useCallback(() => {    if (confirmed.current) return;    confirmed.current = true;    holding.current = false;    setIsHolding(false);    progress.set(1);    setDoneFlash(true);    setAnnounce(`${label}: confirmed`);    onConfirm();    doneTimeout.current = setTimeout(() => {      setDoneFlash(false);      progress.set(0);      confirmed.current = false;      disarm();    }, 640);  }, [disarm, label, onConfirm, progress]);
  const beginHold = useCallback(() => {    if (disabled || reduce) return;    holding.current = true;    confirmed.current = false;    setIsHolding(true);    animRef.current?.stop();    progress.set(0);    animRef.current = animate(progress, 1, {      duration: Math.max(holdMs, 100) / 1000,      ease: "linear",      onComplete: () => {        if (holding.current) finish();      },    });  }, [disabled, finish, holdMs, progress, reduce]);
  const cancelHold = useCallback(() => {    if (!holding.current) return;    holding.current = false;    setIsHolding(false);    if (!confirmed.current) snapBack();  }, [snapBack]);
  useEffect(() => {    if (disabled) {      cancelHold();      disarm();    }  }, [cancelHold, disabled, disarm]);
  useEffect(() => {    return () => {      animRef.current?.stop();      clearArmTimer();      if (doneTimeout.current) clearTimeout(doneTimeout.current);    };  }, [clearArmTimer]);
  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {    if (disabled || reduce) return;    e.currentTarget.setPointerCapture(e.pointerId);    beginHold();  };
  const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {    if (reduce) return;    try {      e.currentTarget.releasePointerCapture(e.pointerId);    } catch {      /* already released */    }    cancelHold();  };
  const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {    if (disabled || reduce) return;    if (e.key === "Escape") {      cancelHold();      return;    }    if ((e.key === "Enter" || e.key === " ") && !e.repeat) {      e.preventDefault();      beginHold();    }  };
  const onKeyUp = (e: React.KeyboardEvent<HTMLButtonElement>) => {    if (reduce) return;    if (e.key === "Enter" || e.key === " ") {      e.preventDefault();      cancelHold();    }  };
  const onClickReduced = () => {    if (disabled || !reduce) return;    if (!armed) {      arm();      return;    }    finish();  };
  const isDestructive = variant === "destructive";  const resolvedIcon =    icon ?? (isDestructive ? <TrashIcon className="size-3.5 shrink-0 opacity-70" /> : null);  const state = doneFlash    ? "confirmed"    : reduce      ? armed        ? "armed"        : "idle"      : isHolding        ? "holding"        : "idle";
  return (    <motion.button      type="button"      disabled={disabled}      data-state={state}      onPointerDown={reduce ? undefined : onPointerDown}      onPointerUp={reduce ? undefined : onPointerUp}      onPointerCancel={reduce ? undefined : onPointerUp}      onLostPointerCapture={reduce ? undefined : cancelHold}      onKeyDown={reduce ? undefined : onKeyDown}      onKeyUp={reduce ? undefined : onKeyUp}      onClick={reduce ? onClickReduced : undefined}      aria-label={        reduce          ? armed            ? armedLabel            : confirmLabel          : `${confirmLabel}: ${label}`      }      style={reduce ? undefined : { scale: pressScale, filter: glowFilter }}      className={cn(        "relative inline-flex h-11 min-w-[12.5rem] items-center justify-center overflow-hidden",        "rounded-[var(--radius-md)] px-5 text-[13px] font-medium tracking-tight",        "outline-none select-none touch-none",        "transition-[background-color,border-color,color] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)]",        "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-paper)]",        "disabled:pointer-events-none disabled:opacity-45",        // Idle surfaces (unaffected by the hold glow, which lives in `filter` above)        isDestructive          ? cn(              "border border-red-500/90 ring-red-500 bg-red-500/10 text-red-800",              "shadow-[0_1px_0_rgba(255,255,255,0.7)_inset,0_1px_2px_rgba(185,28,28,0.06)]",              "dark:border-red-500/25 dark:bg-red-950/45 dark:text-red-100",              "dark:shadow-[0_1px_0_rgba(255,255,255,0.04)_inset,0_1px_2px_rgba(0,0,0,0.35)]",            )          : cn(              "border border-[var(--color-rule)] bg-[var(--color-paper-2)] text-[var(--color-ink)]",              "shadow-[0_1px_0_rgba(255,255,255,0.6)_inset,0_1px_2px_rgba(0,0,0,0.04)]",              "dark:shadow-[0_1px_0_rgba(255,255,255,0.04)_inset]",            ),        // Confirmed flash        doneFlash &&          (isDestructive            ? "border-transparent bg-red-500 text-white shadow-none dark:bg-red-500"            : "border-transparent bg-[var(--color-ink)] text-[var(--color-paper)] shadow-none"),        className,      )}    >      {/* Fill — clip-path, linear during hold */}      {!reduce && (        <motion.span          aria-hidden          className={cn(            "pointer-events-none absolute inset-0 overflow-hidden",            isDestructive              ? "bg-gradient-to-r from-red-600 to-red-500 dark:from-red-500 dark:to-red-400"              : "bg-[var(--color-ink)]",          )}          style={{ clipPath }}        >          <motion.span            className="absolute inset-y-0 w-7 bg-gradient-to-r from-transparent to-white/30 dark:to-white/20"            style={{ left: edgeLeft, opacity: edgeOpacity }}          />        </motion.span>      )}
      <span className="relative z-[1] inline-flex items-center gap-2.5">        {doneFlash ? (          <motion.span            aria-hidden            className="inline-flex size-3.5 items-center justify-center"            initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}            transition={{ type: "spring", duration: 0.3, bounce: 0 }}          >            <svg width="14" height="14" viewBox="0 0 24 24" fill="none">              <motion.path                d="M4 12.5L9.5 18L20 6"                stroke="currentColor"                strokeWidth={2.4}                strokeLinecap="round"                strokeLinejoin="round"                initial={{ pathLength: 0 }}                animate={{ pathLength: 1 }}                transition={{ duration: 0.26, delay: 0.04, ease: EASE_OUT }}              />            </svg>          </motion.span>        ) : (          resolvedIcon        )}
        <span className="relative inline-grid place-items-center">          {reduce ? (            <span>{armed ? armedLabel : label}</span>          ) : (            <>              {/* Sizing layer: box auto-fits the wider of the two possible labels */}              <span aria-hidden className="invisible col-start-1 row-start-1">                {label}              </span>              <span aria-hidden className="invisible col-start-1 row-start-1">                Confirmed              </span>              <motion.span                className="col-start-1 row-start-1"                style={{ opacity: inkIdle, filter: labelFilter }}              >                {label}              </motion.span>              <motion.span                aria-hidden                className="col-start-1 row-start-1 text-white"                style={{ opacity: inkReveal, filter: labelFilter }}              >                {doneFlash ? "Confirmed" : label}              </motion.span>            </>          )}        </span>
        {!reduce && (          <motion.span            className="min-w-[2.25ch] text-right font-mono text-[10px] tabular-nums tracking-wide text-current"            style={{ opacity: pctOpacity }}          >            {pct}          </motion.span>        )}      </span>
      <span className="sr-only" role="status" aria-live="polite">        {announce}      </span>    </motion.button>  );}
function TrashIcon({ className }: { className?: string }) {  return (    <svg      aria-hidden      viewBox="0 0 24 24"      fill="none"      stroke="currentColor"      strokeWidth="1.75"      strokeLinecap="round"      strokeLinejoin="round"      className={className}    >      <path d="M4 7h16" />      <path d="M10 11v6" />      <path d="M14 11v6" />      <path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12" />      <path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />    </svg>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { HoldConfirm } from "@/components/ui/hold-confirm";
<HoldConfirm  label="Delete project"  holdMs={1600}  onConfirm={() => archiveProject()}/>

Props

PropTypeRequiredDefaultDescription
onConfirm() => voidYesFires when the hold completes (or second click under reduced motion).
holdMsnumberNo1600Milliseconds to fill before confirm.
labelstringNo"Delete project"Primary button label.
confirmLabelstringNo"Hold to confirm"Accessible name hint for the hold gesture.
armedLabelstringNo"Press again to confirm"Label after first click when reduced motion is on.
variant"destructive" | "default"No"destructive"Color treatment.
classNamestringNoOptional class names on the button.

Best Practices

  1. Reserve for rare, high-cost actions — not everyday toggles.
  2. Fill uses scaleX from the left (compositor-friendly), not width animation.
  3. Progress percent uses tabular-nums to avoid layout shift.
  4. Pointer capture keeps the hold alive if the finger drifts slightly; leave/cancel still snaps back.