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;};
/** Hold is deliberate: linear clock. Snap-back is a fast spring. */const SNAP = { type: "spring" as const, stiffness: 520, damping: 38, mass: 0.7 };
function readTextSwapDur(): number {  const raw = getComputedStyle(document.documentElement)    .getPropertyValue("--text-swap-dur")    .trim();  if (!raw) return 150;  if (raw.endsWith("ms")) return parseFloat(raw) || 150;  if (raw.endsWith("s")) return (parseFloat(raw) || 0.15) * 1000;  return parseFloat(raw) || 150;}
function swapText(el: HTMLElement, next: string) {  const dur = readTextSwapDur();  el.classList.add("is-exit");  window.setTimeout(() => {    el.textContent = next;    el.classList.remove("is-exit");    el.classList.add("is-enter-start");    void el.offsetHeight;    el.classList.remove("is-enter-start");  }, dur);}
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 textRef = useRef<HTMLSpanElement>(null);  const textFillRef = useRef<HTMLSpanElement>(null);  const buttonRef = useRef<HTMLButtonElement>(null);  const prevDone = useRef(false);
  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)"],  );  // 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]);  // Ring around the icon counts down the hold  const ringOffset = useTransform(progress, (v) => 1 - v);  // Soft sheen on the fill's leading edge  const edgeLeft = useTransform(progress, (v) => `calc(${v * 100}% - 2rem)`);  const edgeOpacity = useTransform(    progress,    [0, 0.04, 0.96, 1],    [0, 1, 1, 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 rgb = variant === "destructive" ? "239 68 68" : "0 0 0";    return `drop-shadow(0 ${(v * 4).toFixed(1)}px ${(v * 14).toFixed(1)}px rgb(${rgb} / ${(v * 0.32).toFixed(2)}))`;  });
  const [armed, setArmed] = useState(false);  const [doneFlash, setDoneFlash] = useState(false);  const [announce, setAnnounce] = useState("");  const [isHolding, setIsHolding] = useState(false);  const [checkState, setCheckState] = useState<"out" | "in">("out");
  // Seed path dash length once the check SVGs mount (one per ink layer)  useEffect(() => {    if (!doneFlash) return;    const button = buttonRef.current;    if (!button) return;    button.querySelectorAll<SVGPathElement>("[data-check-path]").forEach((path) => {      const len = Math.ceil(path.getTotalLength()) + 1;      path.style.strokeDasharray = String(len);      path.style.strokeDashoffset = String(len);    });    const checks = button.querySelectorAll<HTMLElement>("[data-check]");    if (checks.length === 0) return;    checks.forEach((check) => {      check.setAttribute("data-state", "out");      void check.offsetWidth;    });    setCheckState("in");  }, [doneFlash]);
  // Idle ↔ Confirmed text swap  useEffect(() => {    const next = doneFlash ? "Confirmed" : label;    const els = [textRef.current, textFillRef.current].filter(      Boolean,    ) as HTMLSpanElement[];    if (els.length === 0) return;
    if (!prevDone.current && !doneFlash) {      // Cold mount / label prop change without a confirm cycle      els.forEach((el) => {        el.textContent = next;      });      return;    }    if (prevDone.current === doneFlash) {      if (!doneFlash) {        els.forEach((el) => {          el.textContent = label;        });      }      return;    }    prevDone.current = doneFlash;
    if (reduce) {      els.forEach((el) => {        el.textContent = next;      });      return;    }    els.forEach((el) => swapText(el, next));  }, [doneFlash, label, reduce]);
  useEffect(() => {    if (!doneFlash) setCheckState("out");  }, [doneFlash]);
  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" /> : null);  const state = doneFlash    ? "confirmed"    : reduce      ? armed        ? "armed"        : "idle"      : isHolding        ? "holding"        : "idle";
  // Rendered twice: once in the button's ink, once in white inside the fill.  // Both layers share the fill's clip edge, so the label and icon change colour  // exactly where the fill passes over them instead of crossfading at the midpoint.  const renderContent = (layer: "base" | "fill") => (    <span className="relative inline-flex items-center gap-2.5">      {(resolvedIcon || doneFlash) && (        <span className="relative grid size-6 shrink-0 place-items-center">          {!reduce && (            <svg              aria-hidden              viewBox="0 0 24 24"              className="absolute inset-0 size-6 -rotate-90"            >              <circle                cx="12"                cy="12"                r="10.5"                fill="none"                stroke="currentColor"                strokeWidth="1.5"                className="opacity-20"              />              <motion.circle                cx="12"                cy="12"                r="10.5"                fill="none"                stroke="currentColor"                strokeWidth="1.5"                strokeLinecap="round"                pathLength={1}                strokeDasharray="1 1"                style={{ strokeDashoffset: ringOffset }}              />            </svg>          )}          {doneFlash ? (            <span              data-check              className="t-success-check inline-flex size-3.5 items-center justify-center"              data-state={checkState}              aria-hidden              style={                {                  "--check-y-amount": "6px",                  "--check-blur-from": "3px",                  "--check-rotate-from": "36deg",                } as React.CSSProperties              }            >              <svg width="14" height="14" viewBox="0 0 24 24" fill="none">                <path                  data-check-path                  d="M4 12.5L9.5 18L20 6"                  stroke="currentColor"                  strokeWidth={2.4}                  strokeLinecap="round"                  strokeLinejoin="round"                />              </svg>            </span>          ) : (            resolvedIcon          )}        </span>      )}
      <span className="relative inline-grid place-items-center">        {reduce ? (          <span>{armed ? armedLabel : doneFlash ? "Confirmed" : 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>            <span              ref={layer === "base" ? textRef : textFillRef}              className="t-text-swap col-start-1 row-start-1"            >              {label}            </span>          </>        )}      </span>    </span>  );
  return (    <motion.button      ref={buttonRef}      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-full pr-5 pl-2.5 text-[13px] font-medium tracking-tight",        "outline-none select-none touch-none [-webkit-touch-callout:none]",        "transition-[background-color,border-color,color,box-shadow] 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 (the hold glow lives in `filter` above)        isDestructive          ? cn(              "border border-red-500/20 bg-red-500/[0.06] text-red-600",              "shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_1px_2px_rgba(127,29,29,0.06)]",              "hover:border-red-500/30 hover:bg-red-500/10",              "dark:border-red-400/20 dark:bg-red-500/10 dark:text-red-300",              "dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]",              "dark:hover:border-red-400/30 dark:hover:bg-red-500/15",              armed && "border-red-500/40 bg-red-500/15 dark:border-red-400/40",            )          : cn(              "border border-[var(--color-rule)] bg-[var(--color-paper-2)] text-[var(--color-ink)]",              "shadow-[inset_0_1px_0_rgba(255,255,255,0.6),0_1px_2px_rgba(0,0,0,0.04)]",              "hover:border-[var(--color-ink)]/15",              "dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]",            ),        // Confirmed flash        doneFlash &&          (isDestructive            ? "border-transparent bg-red-500 text-white shadow-none dark:bg-red-500 dark:text-white"            : "border-transparent bg-[var(--color-ink)] text-[var(--color-paper)] shadow-none"),        className,      )}    >      {renderContent("base")}
      {/* Fill — clip-path, linear during hold; carries its own white-ink copy */}      {!reduce && (        <motion.span          aria-hidden          className={cn(            "pointer-events-none absolute inset-0 flex items-center justify-center pr-5 pl-2.5",            isDestructive              ? "bg-gradient-to-b from-red-500 to-red-600 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.25)]"              : "bg-[var(--color-ink)] text-[var(--color-paper)]",          )}          style={{ clipPath }}        >          <motion.span            className="absolute inset-y-0 w-8 bg-gradient-to-r from-transparent to-white/35 dark:to-white/20"            style={{ left: edgeLeft, opacity: edgeOpacity }}          />          {renderContent("fill")}        </motion.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() => voidYes—Fires 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.
classNamestringNo—Optional 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.