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.jsonInstall dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useCallback, useEffect, useRef, useState } from "react";4import {5 animate,6 motion,7 useMotionValue,8 useReducedMotion,9 useTransform,10} from "motion/react";11import { cn } from "@/lib/utils";12 13export type HoldConfirmProps = {14 onConfirm: () => void;15 holdMs?: number;16 label?: string;17 confirmLabel?: string;18 armedLabel?: string;19 variant?: "destructive" | "default";20 className?: string;21 disabled?: boolean;22 /** Optional leading icon. Defaults to a trash glyph for the destructive variant, none otherwise. */23 icon?: React.ReactNode;24 /** How long the "armed" state lasts before auto-resetting (reduced-motion fallback). */25 armWindowMs?: number;26};27 28/** Hold is deliberate: linear clock. Snap-back is a fast spring. */29const SNAP = { type: "spring" as const, stiffness: 520, damping: 38, mass: 0.7 };30 31function readTextSwapDur(): number {32 const raw = getComputedStyle(document.documentElement)33 .getPropertyValue("--text-swap-dur")34 .trim();35 if (!raw) return 150;36 if (raw.endsWith("ms")) return parseFloat(raw) || 150;37 if (raw.endsWith("s")) return (parseFloat(raw) || 0.15) * 1000;38 return parseFloat(raw) || 150;39}40 41function swapText(el: HTMLElement, next: string) {42 const dur = readTextSwapDur();43 el.classList.add("is-exit");44 window.setTimeout(() => {45 el.textContent = next;46 el.classList.remove("is-exit");47 el.classList.add("is-enter-start");48 void el.offsetHeight;49 el.classList.remove("is-enter-start");50 }, dur);51}52 53export function HoldConfirm({54 onConfirm,55 holdMs = 1600,56 label = "Delete project",57 confirmLabel = "Hold to confirm",58 armedLabel = "Press again to confirm",59 variant = "destructive",60 className,61 disabled,62 icon,63 armWindowMs = 3000,64}: HoldConfirmProps) {65 const reduce = useReducedMotion();66 const holding = useRef(false);67 const confirmed = useRef(false);68 const animRef = useRef<ReturnType<typeof animate> | null>(null);69 const armTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);70 const doneTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);71 const textRef = useRef<HTMLSpanElement>(null);72 const textFillRef = useRef<HTMLSpanElement>(null);73 const checkRef = useRef<HTMLSpanElement>(null);74 const pathRef = useRef<SVGPathElement>(null);75 const prevDone = useRef(false);76 77 const progress = useMotionValue(0);78 // clip-path fill — full-size element, only visually clipped, so the sheen child79 // keeps normal geometry instead of getting squashed by a scaleX transform.80 const clipPath = useTransform(81 progress,82 [0, 1],83 ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],84 );85 // Label ink flips as the fill crosses the midline86 const inkReveal = useTransform(progress, [0.42, 0.58], [0, 1]);87 const inkIdle = useTransform(progress, [0.42, 0.58], [1, 0]);88 // Soft blur bridges the crossfade so two labels don't stack as distinct objects89 const labelBlur = useTransform(progress, [0.4, 0.5, 0.6], [0, 2, 0]);90 const labelFilter = useTransform(labelBlur, (b) => `blur(${b}px)`);91 // Progress % fades in only while holding92 const pctOpacity = useTransform(progress, [0.02, 0.12, 0.92, 1], [0, 1, 1, 0]);93 // Subtle press scale driven by progress (settles at ~0.97 while held)94 const pressScale = useTransform(progress, [0, 0.08, 1], [1, 0.97, 0.97]);95 // Soft sheen on the fill's leading edge96 const edgeLeft = useTransform(progress, (v) => `calc(${v * 100}% - 1.75rem)`);97 const edgeOpacity = useTransform(98 progress,99 [0, 0.04, 0.96, 1],100 [0, 0.85, 0.85, 0],101 );102 // Ambient "charging" glow — a drop-shadow (not box-shadow) so it isn't clipped103 // by the button's own overflow-hidden, and can't fight the idle box-shadow classes.104 const glowFilter = useTransform(progress, (v) => {105 const isDestructive = variant === "destructive";106 const color = isDestructive ? "var(--color-red-500)" : "var(--color-neutral-200)";107 return `drop-shadow(0 0 ${(v * 5).toFixed(1)}px rgba(${color},${(v * 0.3).toFixed(2)}))`;108 });109 110 const [pct, setPct] = useState(0);111 const [armed, setArmed] = useState(false);112 const [doneFlash, setDoneFlash] = useState(false);113 const [announce, setAnnounce] = useState("");114 const [isHolding, setIsHolding] = useState(false);115 const [checkState, setCheckState] = useState<"out" | "in">("out");116 117 useEffect(() => {118 return progress.on("change", (v) =>119 setPct(Math.round(Math.min(1, Math.max(0, v)) * 100)),120 );121 }, [progress]);122 123 // Seed path dash length once the check SVG mounts124 useEffect(() => {125 if (!doneFlash) return;126 const path = pathRef.current;127 if (!path) return;128 const len = Math.ceil(path.getTotalLength()) + 1;129 path.style.strokeDasharray = String(len);130 path.style.strokeDashoffset = String(len);131 const check = checkRef.current;132 if (check) {133 check.setAttribute("data-state", "out");134 void check.offsetWidth;135 setCheckState("in");136 }137 }, [doneFlash]);138 139 // Idle ↔ Confirmed text swap140 useEffect(() => {141 const next = doneFlash ? "Confirmed" : label;142 const els = [textRef.current, textFillRef.current].filter(143 Boolean,144 ) as HTMLSpanElement[];145 if (els.length === 0) return;146 147 if (!prevDone.current && !doneFlash) {148 // Cold mount / label prop change without a confirm cycle149 els.forEach((el) => {150 el.textContent = next;151 });152 return;153 }154 if (prevDone.current === doneFlash) {155 if (!doneFlash) {156 els.forEach((el) => {157 el.textContent = label;158 });159 }160 return;161 }162 prevDone.current = doneFlash;163 164 if (reduce) {165 els.forEach((el) => {166 el.textContent = next;167 });168 return;169 }170 els.forEach((el) => swapText(el, next));171 }, [doneFlash, label, reduce]);172 173 useEffect(() => {174 if (!doneFlash) setCheckState("out");175 }, [doneFlash]);176 177 const clearArmTimer = useCallback(() => {178 if (armTimeout.current) {179 clearTimeout(armTimeout.current);180 armTimeout.current = null;181 }182 }, []);183 184 const disarm = useCallback(() => {185 clearArmTimer();186 setArmed(false);187 }, [clearArmTimer]);188 189 const arm = useCallback(() => {190 setArmed(true);191 clearArmTimer();192 armTimeout.current = setTimeout(disarm, armWindowMs);193 }, [armWindowMs, clearArmTimer, disarm]);194 195 const snapBack = useCallback(() => {196 animRef.current?.stop();197 // Asymmetric: release is snappy (hold was slow + linear)198 animRef.current = animate(progress, 0, SNAP);199 }, [progress]);200 201 const finish = useCallback(() => {202 if (confirmed.current) return;203 confirmed.current = true;204 holding.current = false;205 setIsHolding(false);206 progress.set(1);207 setDoneFlash(true);208 setAnnounce(`${label}: confirmed`);209 onConfirm();210 doneTimeout.current = setTimeout(() => {211 setDoneFlash(false);212 progress.set(0);213 confirmed.current = false;214 disarm();215 }, 640);216 }, [disarm, label, onConfirm, progress]);217 218 const beginHold = useCallback(() => {219 if (disabled || reduce) return;220 holding.current = true;221 confirmed.current = false;222 setIsHolding(true);223 animRef.current?.stop();224 progress.set(0);225 animRef.current = animate(progress, 1, {226 duration: Math.max(holdMs, 100) / 1000,227 ease: "linear",228 onComplete: () => {229 if (holding.current) finish();230 },231 });232 }, [disabled, finish, holdMs, progress, reduce]);233 234 const cancelHold = useCallback(() => {235 if (!holding.current) return;236 holding.current = false;237 setIsHolding(false);238 if (!confirmed.current) snapBack();239 }, [snapBack]);240 241 useEffect(() => {242 if (disabled) {243 cancelHold();244 disarm();245 }246 }, [cancelHold, disabled, disarm]);247 248 useEffect(() => {249 return () => {250 animRef.current?.stop();251 clearArmTimer();252 if (doneTimeout.current) clearTimeout(doneTimeout.current);253 };254 }, [clearArmTimer]);255 256 const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {257 if (disabled || reduce) return;258 e.currentTarget.setPointerCapture(e.pointerId);259 beginHold();260 };261 262 const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {263 if (reduce) return;264 try {265 e.currentTarget.releasePointerCapture(e.pointerId);266 } catch {267 /* already released */268 }269 cancelHold();270 };271 272 const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {273 if (disabled || reduce) return;274 if (e.key === "Escape") {275 cancelHold();276 return;277 }278 if ((e.key === "Enter" || e.key === " ") && !e.repeat) {279 e.preventDefault();280 beginHold();281 }282 };283 284 const onKeyUp = (e: React.KeyboardEvent<HTMLButtonElement>) => {285 if (reduce) return;286 if (e.key === "Enter" || e.key === " ") {287 e.preventDefault();288 cancelHold();289 }290 };291 292 const onClickReduced = () => {293 if (disabled || !reduce) return;294 if (!armed) {295 arm();296 return;297 }298 finish();299 };300 301 const isDestructive = variant === "destructive";302 const resolvedIcon =303 icon ?? (isDestructive ? <TrashIcon className="size-3.5 shrink-0 opacity-70" /> : null);304 const state = doneFlash305 ? "confirmed"306 : reduce307 ? armed308 ? "armed"309 : "idle"310 : isHolding311 ? "holding"312 : "idle";313 314 return (315 <motion.button316 type="button"317 disabled={disabled}318 data-state={state}319 onPointerDown={reduce ? undefined : onPointerDown}320 onPointerUp={reduce ? undefined : onPointerUp}321 onPointerCancel={reduce ? undefined : onPointerUp}322 onLostPointerCapture={reduce ? undefined : cancelHold}323 onKeyDown={reduce ? undefined : onKeyDown}324 onKeyUp={reduce ? undefined : onKeyUp}325 onClick={reduce ? onClickReduced : undefined}326 aria-label={327 reduce328 ? armed329 ? armedLabel330 : confirmLabel331 : `${confirmLabel}: ${label}`332 }333 style={reduce ? undefined : { scale: pressScale, filter: glowFilter }}334 className={cn(335 "relative inline-flex h-11 min-w-[12.5rem] items-center justify-center overflow-hidden",336 "rounded-[var(--radius-md)] px-5 text-[13px] font-medium tracking-tight",337 "outline-none select-none touch-none",338 "transition-[background-color,border-color,color] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)]",339 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-paper)]",340 "disabled:pointer-events-none disabled:opacity-45",341 // Idle surfaces (unaffected by the hold glow, which lives in `filter` above)342 isDestructive343 ? cn(344 "border border-red-500/90 ring-red-500 bg-red-500/10 text-red-800",345 "shadow-[0_1px_0_rgba(255,255,255,0.7)_inset,0_1px_2px_rgba(185,28,28,0.06)]",346 "dark:border-red-500/25 dark:bg-red-950/45 dark:text-red-100",347 "dark:shadow-[0_1px_0_rgba(255,255,255,0.04)_inset,0_1px_2px_rgba(0,0,0,0.35)]",348 )349 : cn(350 "border border-[var(--color-rule)] bg-[var(--color-paper-2)] text-[var(--color-ink)]",351 "shadow-[0_1px_0_rgba(255,255,255,0.6)_inset,0_1px_2px_rgba(0,0,0,0.04)]",352 "dark:shadow-[0_1px_0_rgba(255,255,255,0.04)_inset]",353 ),354 // Confirmed flash355 doneFlash &&356 (isDestructive357 ? "border-transparent bg-red-500 text-white shadow-none dark:bg-red-500"358 : "border-transparent bg-[var(--color-ink)] text-[var(--color-paper)] shadow-none"),359 className,360 )}361 >362 {/* Fill — clip-path, linear during hold */}363 {!reduce && (364 <motion.span365 aria-hidden366 className={cn(367 "pointer-events-none absolute inset-0 overflow-hidden",368 isDestructive369 ? "bg-gradient-to-r from-red-600 to-red-500 dark:from-red-500 dark:to-red-400"370 : "bg-[var(--color-ink)]",371 )}372 style={{ clipPath }}373 >374 <motion.span375 className="absolute inset-y-0 w-7 bg-gradient-to-r from-transparent to-white/30 dark:to-white/20"376 style={{ left: edgeLeft, opacity: edgeOpacity }}377 />378 </motion.span>379 )}380 381 <span className="relative z-[1] inline-flex items-center gap-2.5">382 {doneFlash ? (383 <span384 ref={checkRef}385 className="t-success-check inline-flex size-3.5 items-center justify-center"386 data-state={checkState}387 aria-hidden388 style={389 {390 "--check-y-amount": "6px",391 "--check-blur-from": "3px",392 "--check-rotate-from": "36deg",393 } as React.CSSProperties394 }395 >396 <svg width="14" height="14" viewBox="0 0 24 24" fill="none">397 <path398 ref={pathRef}399 d="M4 12.5L9.5 18L20 6"400 stroke="currentColor"401 strokeWidth={2.4}402 strokeLinecap="round"403 strokeLinejoin="round"404 />405 </svg>406 </span>407 ) : (408 resolvedIcon409 )}410 411 <span className="relative inline-grid place-items-center">412 {reduce ? (413 <span>{armed ? armedLabel : doneFlash ? "Confirmed" : label}</span>414 ) : (415 <>416 {/* Sizing layer: box auto-fits the wider of the two possible labels */}417 <span aria-hidden className="invisible col-start-1 row-start-1">418 {label}419 </span>420 <span aria-hidden className="invisible col-start-1 row-start-1">421 Confirmed422 </span>423 <motion.span424 className="col-start-1 row-start-1"425 style={{ opacity: inkIdle, filter: labelFilter }}426 >427 <span ref={textRef} className="t-text-swap">428 {label}429 </span>430 </motion.span>431 <motion.span432 aria-hidden433 className="col-start-1 row-start-1 text-white"434 style={{ opacity: inkReveal, filter: labelFilter }}435 >436 <span ref={textFillRef} className="t-text-swap">437 {label}438 </span>439 </motion.span>440 </>441 )}442 </span>443 444 {!reduce && (445 <motion.span446 className="min-w-[2.25ch] text-right font-mono text-[10px] tabular-nums tracking-wide text-current"447 style={{ opacity: pctOpacity }}448 >449 {pct}450 </motion.span>451 )}452 </span>453 454 <span className="sr-only" role="status" aria-live="polite">455 {announce}456 </span>457 </motion.button>458 );459}460 461function TrashIcon({ className }: { className?: string }) {462 return (463 <svg464 aria-hidden465 viewBox="0 0 24 24"466 fill="none"467 stroke="currentColor"468 strokeWidth="1.75"469 strokeLinecap="round"470 strokeLinejoin="round"471 className={className}472 >473 <path d="M4 7h16" />474 <path d="M10 11v6" />475 <path d="M14 11v6" />476 <path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12" />477 <path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />478 </svg>479 );480}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { HoldConfirm } from "@/components/ui/hold-confirm";2 3<HoldConfirm4 label="Delete project"5 holdMs={1600}6 onConfirm={() => archiveProject()}7/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| onConfirm | () => void | Yes | — | Fires when the hold completes (or second click under reduced motion). |
| holdMs | number | No | 1600 | Milliseconds to fill before confirm. |
| label | string | No | "Delete project" | Primary button label. |
| confirmLabel | string | No | "Hold to confirm" | Accessible name hint for the hold gesture. |
| armedLabel | string | No | "Press again to confirm" | Label after first click when reduced motion is on. |
| variant | "destructive" | "default" | No | "destructive" | Color treatment. |
| className | string | No | — | Optional class names on the button. |
Best Practices
- Reserve for rare, high-cost actions — not everyday toggles.
- Fill uses
scaleXfrom the left (compositor-friendly), not width animation. - Progress percent uses
tabular-numsto avoid layout shift. - Pointer capture keeps the hold alive if the finger drifts slightly; leave/cancel still snaps back.