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 buttonRef = useRef<HTMLButtonElement>(null);74 const prevDone = useRef(false);75 76 const progress = useMotionValue(0);77 // clip-path fill — full-size element, only visually clipped, so the sheen child78 // keeps normal geometry instead of getting squashed by a scaleX transform.79 const clipPath = useTransform(80 progress,81 [0, 1],82 ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],83 );84 // Subtle press scale driven by progress (settles at ~0.97 while held)85 const pressScale = useTransform(progress, [0, 0.08, 1], [1, 0.97, 0.97]);86 // Ring around the icon counts down the hold87 const ringOffset = useTransform(progress, (v) => 1 - v);88 // Soft sheen on the fill's leading edge89 const edgeLeft = useTransform(progress, (v) => `calc(${v * 100}% - 2rem)`);90 const edgeOpacity = useTransform(91 progress,92 [0, 0.04, 0.96, 1],93 [0, 1, 1, 0],94 );95 // Ambient "charging" glow — a drop-shadow (not box-shadow) so it isn't clipped96 // by the button's own overflow-hidden, and can't fight the idle box-shadow classes.97 const glowFilter = useTransform(progress, (v) => {98 const rgb = variant === "destructive" ? "239 68 68" : "0 0 0";99 return `drop-shadow(0 ${(v * 4).toFixed(1)}px ${(v * 14).toFixed(1)}px rgb(${rgb} / ${(v * 0.32).toFixed(2)}))`;100 });101 102 const [armed, setArmed] = useState(false);103 const [doneFlash, setDoneFlash] = useState(false);104 const [announce, setAnnounce] = useState("");105 const [isHolding, setIsHolding] = useState(false);106 const [checkState, setCheckState] = useState<"out" | "in">("out");107 108 // Seed path dash length once the check SVGs mount (one per ink layer)109 useEffect(() => {110 if (!doneFlash) return;111 const button = buttonRef.current;112 if (!button) return;113 button.querySelectorAll<SVGPathElement>("[data-check-path]").forEach((path) => {114 const len = Math.ceil(path.getTotalLength()) + 1;115 path.style.strokeDasharray = String(len);116 path.style.strokeDashoffset = String(len);117 });118 const checks = button.querySelectorAll<HTMLElement>("[data-check]");119 if (checks.length === 0) return;120 checks.forEach((check) => {121 check.setAttribute("data-state", "out");122 void check.offsetWidth;123 });124 setCheckState("in");125 }, [doneFlash]);126 127 // Idle ↔ Confirmed text swap128 useEffect(() => {129 const next = doneFlash ? "Confirmed" : label;130 const els = [textRef.current, textFillRef.current].filter(131 Boolean,132 ) as HTMLSpanElement[];133 if (els.length === 0) return;134 135 if (!prevDone.current && !doneFlash) {136 // Cold mount / label prop change without a confirm cycle137 els.forEach((el) => {138 el.textContent = next;139 });140 return;141 }142 if (prevDone.current === doneFlash) {143 if (!doneFlash) {144 els.forEach((el) => {145 el.textContent = label;146 });147 }148 return;149 }150 prevDone.current = doneFlash;151 152 if (reduce) {153 els.forEach((el) => {154 el.textContent = next;155 });156 return;157 }158 els.forEach((el) => swapText(el, next));159 }, [doneFlash, label, reduce]);160 161 useEffect(() => {162 if (!doneFlash) setCheckState("out");163 }, [doneFlash]);164 165 const clearArmTimer = useCallback(() => {166 if (armTimeout.current) {167 clearTimeout(armTimeout.current);168 armTimeout.current = null;169 }170 }, []);171 172 const disarm = useCallback(() => {173 clearArmTimer();174 setArmed(false);175 }, [clearArmTimer]);176 177 const arm = useCallback(() => {178 setArmed(true);179 clearArmTimer();180 armTimeout.current = setTimeout(disarm, armWindowMs);181 }, [armWindowMs, clearArmTimer, disarm]);182 183 const snapBack = useCallback(() => {184 animRef.current?.stop();185 // Asymmetric: release is snappy (hold was slow + linear)186 animRef.current = animate(progress, 0, SNAP);187 }, [progress]);188 189 const finish = useCallback(() => {190 if (confirmed.current) return;191 confirmed.current = true;192 holding.current = false;193 setIsHolding(false);194 progress.set(1);195 setDoneFlash(true);196 setAnnounce(`${label}: confirmed`);197 onConfirm();198 doneTimeout.current = setTimeout(() => {199 setDoneFlash(false);200 progress.set(0);201 confirmed.current = false;202 disarm();203 }, 640);204 }, [disarm, label, onConfirm, progress]);205 206 const beginHold = useCallback(() => {207 if (disabled || reduce) return;208 holding.current = true;209 confirmed.current = false;210 setIsHolding(true);211 animRef.current?.stop();212 progress.set(0);213 animRef.current = animate(progress, 1, {214 duration: Math.max(holdMs, 100) / 1000,215 ease: "linear",216 onComplete: () => {217 if (holding.current) finish();218 },219 });220 }, [disabled, finish, holdMs, progress, reduce]);221 222 const cancelHold = useCallback(() => {223 if (!holding.current) return;224 holding.current = false;225 setIsHolding(false);226 if (!confirmed.current) snapBack();227 }, [snapBack]);228 229 useEffect(() => {230 if (disabled) {231 cancelHold();232 disarm();233 }234 }, [cancelHold, disabled, disarm]);235 236 useEffect(() => {237 return () => {238 animRef.current?.stop();239 clearArmTimer();240 if (doneTimeout.current) clearTimeout(doneTimeout.current);241 };242 }, [clearArmTimer]);243 244 const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {245 if (disabled || reduce) return;246 e.currentTarget.setPointerCapture(e.pointerId);247 beginHold();248 };249 250 const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {251 if (reduce) return;252 try {253 e.currentTarget.releasePointerCapture(e.pointerId);254 } catch {255 /* already released */256 }257 cancelHold();258 };259 260 const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {261 if (disabled || reduce) return;262 if (e.key === "Escape") {263 cancelHold();264 return;265 }266 if ((e.key === "Enter" || e.key === " ") && !e.repeat) {267 e.preventDefault();268 beginHold();269 }270 };271 272 const onKeyUp = (e: React.KeyboardEvent<HTMLButtonElement>) => {273 if (reduce) return;274 if (e.key === "Enter" || e.key === " ") {275 e.preventDefault();276 cancelHold();277 }278 };279 280 const onClickReduced = () => {281 if (disabled || !reduce) return;282 if (!armed) {283 arm();284 return;285 }286 finish();287 };288 289 const isDestructive = variant === "destructive";290 const resolvedIcon =291 icon ?? (isDestructive ? <TrashIcon className="size-3.5" /> : null);292 const state = doneFlash293 ? "confirmed"294 : reduce295 ? armed296 ? "armed"297 : "idle"298 : isHolding299 ? "holding"300 : "idle";301 302 // Rendered twice: once in the button's ink, once in white inside the fill.303 // Both layers share the fill's clip edge, so the label and icon change colour304 // exactly where the fill passes over them instead of crossfading at the midpoint.305 const renderContent = (layer: "base" | "fill") => (306 <span className="relative inline-flex items-center gap-2.5">307 {(resolvedIcon || doneFlash) && (308 <span className="relative grid size-6 shrink-0 place-items-center">309 {!reduce && (310 <svg311 aria-hidden312 viewBox="0 0 24 24"313 className="absolute inset-0 size-6 -rotate-90"314 >315 <circle316 cx="12"317 cy="12"318 r="10.5"319 fill="none"320 stroke="currentColor"321 strokeWidth="1.5"322 className="opacity-20"323 />324 <motion.circle325 cx="12"326 cy="12"327 r="10.5"328 fill="none"329 stroke="currentColor"330 strokeWidth="1.5"331 strokeLinecap="round"332 pathLength={1}333 strokeDasharray="1 1"334 style={{ strokeDashoffset: ringOffset }}335 />336 </svg>337 )}338 {doneFlash ? (339 <span340 data-check341 className="t-success-check inline-flex size-3.5 items-center justify-center"342 data-state={checkState}343 aria-hidden344 style={345 {346 "--check-y-amount": "6px",347 "--check-blur-from": "3px",348 "--check-rotate-from": "36deg",349 } as React.CSSProperties350 }351 >352 <svg width="14" height="14" viewBox="0 0 24 24" fill="none">353 <path354 data-check-path355 d="M4 12.5L9.5 18L20 6"356 stroke="currentColor"357 strokeWidth={2.4}358 strokeLinecap="round"359 strokeLinejoin="round"360 />361 </svg>362 </span>363 ) : (364 resolvedIcon365 )}366 </span>367 )}368 369 <span className="relative inline-grid place-items-center">370 {reduce ? (371 <span>{armed ? armedLabel : doneFlash ? "Confirmed" : label}</span>372 ) : (373 <>374 {/* Sizing layer: box auto-fits the wider of the two possible labels */}375 <span aria-hidden className="invisible col-start-1 row-start-1">376 {label}377 </span>378 <span aria-hidden className="invisible col-start-1 row-start-1">379 Confirmed380 </span>381 <span382 ref={layer === "base" ? textRef : textFillRef}383 className="t-text-swap col-start-1 row-start-1"384 >385 {label}386 </span>387 </>388 )}389 </span>390 </span>391 );392 393 return (394 <motion.button395 ref={buttonRef}396 type="button"397 disabled={disabled}398 data-state={state}399 onPointerDown={reduce ? undefined : onPointerDown}400 onPointerUp={reduce ? undefined : onPointerUp}401 onPointerCancel={reduce ? undefined : onPointerUp}402 onLostPointerCapture={reduce ? undefined : cancelHold}403 onKeyDown={reduce ? undefined : onKeyDown}404 onKeyUp={reduce ? undefined : onKeyUp}405 onClick={reduce ? onClickReduced : undefined}406 aria-label={407 reduce408 ? armed409 ? armedLabel410 : confirmLabel411 : `${confirmLabel}: ${label}`412 }413 style={reduce ? undefined : { scale: pressScale, filter: glowFilter }}414 className={cn(415 "relative inline-flex h-11 min-w-[12.5rem] items-center justify-center overflow-hidden",416 "rounded-full pr-5 pl-2.5 text-[13px] font-medium tracking-tight",417 "outline-none select-none touch-none [-webkit-touch-callout:none]",418 "transition-[background-color,border-color,color,box-shadow] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)]",419 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-paper)]",420 "disabled:pointer-events-none disabled:opacity-45",421 // Idle surfaces (the hold glow lives in `filter` above)422 isDestructive423 ? cn(424 "border border-red-500/20 bg-red-500/[0.06] text-red-600",425 "shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_1px_2px_rgba(127,29,29,0.06)]",426 "hover:border-red-500/30 hover:bg-red-500/10",427 "dark:border-red-400/20 dark:bg-red-500/10 dark:text-red-300",428 "dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]",429 "dark:hover:border-red-400/30 dark:hover:bg-red-500/15",430 armed && "border-red-500/40 bg-red-500/15 dark:border-red-400/40",431 )432 : cn(433 "border border-[var(--color-rule)] bg-[var(--color-paper-2)] text-[var(--color-ink)]",434 "shadow-[inset_0_1px_0_rgba(255,255,255,0.6),0_1px_2px_rgba(0,0,0,0.04)]",435 "hover:border-[var(--color-ink)]/15",436 "dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]",437 ),438 // Confirmed flash439 doneFlash &&440 (isDestructive441 ? "border-transparent bg-red-500 text-white shadow-none dark:bg-red-500 dark:text-white"442 : "border-transparent bg-[var(--color-ink)] text-[var(--color-paper)] shadow-none"),443 className,444 )}445 >446 {renderContent("base")}447 448 {/* Fill — clip-path, linear during hold; carries its own white-ink copy */}449 {!reduce && (450 <motion.span451 aria-hidden452 className={cn(453 "pointer-events-none absolute inset-0 flex items-center justify-center pr-5 pl-2.5",454 isDestructive455 ? "bg-gradient-to-b from-red-500 to-red-600 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.25)]"456 : "bg-[var(--color-ink)] text-[var(--color-paper)]",457 )}458 style={{ clipPath }}459 >460 <motion.span461 className="absolute inset-y-0 w-8 bg-gradient-to-r from-transparent to-white/35 dark:to-white/20"462 style={{ left: edgeLeft, opacity: edgeOpacity }}463 />464 {renderContent("fill")}465 </motion.span>466 )}467 468 <span className="sr-only" role="status" aria-live="polite">469 {announce}470 </span>471 </motion.button>472 );473}474 475function TrashIcon({ className }: { className?: string }) {476 return (477 <svg478 aria-hidden479 viewBox="0 0 24 24"480 fill="none"481 stroke="currentColor"482 strokeWidth="1.75"483 strokeLinecap="round"484 strokeLinejoin="round"485 className={className}486 >487 <path d="M4 7h16" />488 <path d="M10 11v6" />489 <path d="M14 11v6" />490 <path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12" />491 <path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />492 </svg>493 );494}
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.