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/** Strong ease-out — release / success must feel instant */29const EASE_OUT = [0.16, 1, 0.3, 1] as const;30/** Hold is deliberate: linear clock. Snap-back is a fast spring. */31const SNAP = { type: "spring" as const, stiffness: 520, damping: 38, mass: 0.7 };32 33export function HoldConfirm({34 onConfirm,35 holdMs = 1600,36 label = "Delete project",37 confirmLabel = "Hold to confirm",38 armedLabel = "Press again to confirm",39 variant = "destructive",40 className,41 disabled,42 icon,43 armWindowMs = 3000,44}: HoldConfirmProps) {45 const reduce = useReducedMotion();46 const holding = useRef(false);47 const confirmed = useRef(false);48 const animRef = useRef<ReturnType<typeof animate> | null>(null);49 const armTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);50 const doneTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);51 52 const progress = useMotionValue(0);53 // clip-path fill — full-size element, only visually clipped, so the sheen child54 // keeps normal geometry instead of getting squashed by a scaleX transform.55 const clipPath = useTransform(56 progress,57 [0, 1],58 ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],59 );60 // Label ink flips as the fill crosses the midline61 const inkReveal = useTransform(progress, [0.42, 0.58], [0, 1]);62 const inkIdle = useTransform(progress, [0.42, 0.58], [1, 0]);63 // Soft blur bridges the crossfade so two labels don't stack as distinct objects64 const labelBlur = useTransform(progress, [0.4, 0.5, 0.6], [0, 2, 0]);65 const labelFilter = useTransform(labelBlur, (b) => `blur(${b}px)`);66 // Progress % fades in only while holding67 const pctOpacity = useTransform(progress, [0.02, 0.12, 0.92, 1], [0, 1, 1, 0]);68 // Subtle press scale driven by progress (settles at ~0.97 while held)69 const pressScale = useTransform(progress, [0, 0.08, 1], [1, 0.97, 0.97]);70 // Soft sheen on the fill's leading edge71 const edgeLeft = useTransform(progress, (v) => `calc(${v * 100}% - 1.75rem)`);72 const edgeOpacity = useTransform(73 progress,74 [0, 0.04, 0.96, 1],75 [0, 0.85, 0.85, 0],76 );77 // Ambient "charging" glow — a drop-shadow (not box-shadow) so it isn't clipped78 // by the button's own overflow-hidden, and can't fight the idle box-shadow classes.79 const glowFilter = useTransform(progress, (v) => {80 const isDestructive = variant === "destructive";81 const color = isDestructive ? "var(--color-red-500)" : "var(--color-neutral-200)";82 return `drop-shadow(0 0 ${(v * 5).toFixed(1)}px rgba(${color},${(v * 0.3).toFixed(2)}))`;83 });84 85 const [pct, setPct] = useState(0);86 const [armed, setArmed] = useState(false);87 const [doneFlash, setDoneFlash] = useState(false);88 const [announce, setAnnounce] = useState("");89 const [isHolding, setIsHolding] = useState(false);90 91 useEffect(() => {92 return progress.on("change", (v) =>93 setPct(Math.round(Math.min(1, Math.max(0, v)) * 100)),94 );95 }, [progress]);96 97 const clearArmTimer = useCallback(() => {98 if (armTimeout.current) {99 clearTimeout(armTimeout.current);100 armTimeout.current = null;101 }102 }, []);103 104 const disarm = useCallback(() => {105 clearArmTimer();106 setArmed(false);107 }, [clearArmTimer]);108 109 const arm = useCallback(() => {110 setArmed(true);111 clearArmTimer();112 armTimeout.current = setTimeout(disarm, armWindowMs);113 }, [armWindowMs, clearArmTimer, disarm]);114 115 const snapBack = useCallback(() => {116 animRef.current?.stop();117 // Asymmetric: release is snappy (hold was slow + linear)118 animRef.current = animate(progress, 0, SNAP);119 }, [progress]);120 121 const finish = useCallback(() => {122 if (confirmed.current) return;123 confirmed.current = true;124 holding.current = false;125 setIsHolding(false);126 progress.set(1);127 setDoneFlash(true);128 setAnnounce(`${label}: confirmed`);129 onConfirm();130 doneTimeout.current = setTimeout(() => {131 setDoneFlash(false);132 progress.set(0);133 confirmed.current = false;134 disarm();135 }, 640);136 }, [disarm, label, onConfirm, progress]);137 138 const beginHold = useCallback(() => {139 if (disabled || reduce) return;140 holding.current = true;141 confirmed.current = false;142 setIsHolding(true);143 animRef.current?.stop();144 progress.set(0);145 animRef.current = animate(progress, 1, {146 duration: Math.max(holdMs, 100) / 1000,147 ease: "linear",148 onComplete: () => {149 if (holding.current) finish();150 },151 });152 }, [disabled, finish, holdMs, progress, reduce]);153 154 const cancelHold = useCallback(() => {155 if (!holding.current) return;156 holding.current = false;157 setIsHolding(false);158 if (!confirmed.current) snapBack();159 }, [snapBack]);160 161 useEffect(() => {162 if (disabled) {163 cancelHold();164 disarm();165 }166 }, [cancelHold, disabled, disarm]);167 168 useEffect(() => {169 return () => {170 animRef.current?.stop();171 clearArmTimer();172 if (doneTimeout.current) clearTimeout(doneTimeout.current);173 };174 }, [clearArmTimer]);175 176 const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {177 if (disabled || reduce) return;178 e.currentTarget.setPointerCapture(e.pointerId);179 beginHold();180 };181 182 const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {183 if (reduce) return;184 try {185 e.currentTarget.releasePointerCapture(e.pointerId);186 } catch {187 /* already released */188 }189 cancelHold();190 };191 192 const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {193 if (disabled || reduce) return;194 if (e.key === "Escape") {195 cancelHold();196 return;197 }198 if ((e.key === "Enter" || e.key === " ") && !e.repeat) {199 e.preventDefault();200 beginHold();201 }202 };203 204 const onKeyUp = (e: React.KeyboardEvent<HTMLButtonElement>) => {205 if (reduce) return;206 if (e.key === "Enter" || e.key === " ") {207 e.preventDefault();208 cancelHold();209 }210 };211 212 const onClickReduced = () => {213 if (disabled || !reduce) return;214 if (!armed) {215 arm();216 return;217 }218 finish();219 };220 221 const isDestructive = variant === "destructive";222 const resolvedIcon =223 icon ?? (isDestructive ? <TrashIcon className="size-3.5 shrink-0 opacity-70" /> : null);224 const state = doneFlash225 ? "confirmed"226 : reduce227 ? armed228 ? "armed"229 : "idle"230 : isHolding231 ? "holding"232 : "idle";233 234 return (235 <motion.button236 type="button"237 disabled={disabled}238 data-state={state}239 onPointerDown={reduce ? undefined : onPointerDown}240 onPointerUp={reduce ? undefined : onPointerUp}241 onPointerCancel={reduce ? undefined : onPointerUp}242 onLostPointerCapture={reduce ? undefined : cancelHold}243 onKeyDown={reduce ? undefined : onKeyDown}244 onKeyUp={reduce ? undefined : onKeyUp}245 onClick={reduce ? onClickReduced : undefined}246 aria-label={247 reduce248 ? armed249 ? armedLabel250 : confirmLabel251 : `${confirmLabel}: ${label}`252 }253 style={reduce ? undefined : { scale: pressScale, filter: glowFilter }}254 className={cn(255 "relative inline-flex h-11 min-w-[12.5rem] items-center justify-center overflow-hidden",256 "rounded-[var(--radius-md)] px-5 text-[13px] font-medium tracking-tight",257 "outline-none select-none touch-none",258 "transition-[background-color,border-color,color] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)]",259 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-paper)]",260 "disabled:pointer-events-none disabled:opacity-45",261 // Idle surfaces (unaffected by the hold glow, which lives in `filter` above)262 isDestructive263 ? cn(264 "border border-red-500/90 ring-red-500 bg-red-500/10 text-red-800",265 "shadow-[0_1px_0_rgba(255,255,255,0.7)_inset,0_1px_2px_rgba(185,28,28,0.06)]",266 "dark:border-red-500/25 dark:bg-red-950/45 dark:text-red-100",267 "dark:shadow-[0_1px_0_rgba(255,255,255,0.04)_inset,0_1px_2px_rgba(0,0,0,0.35)]",268 )269 : cn(270 "border border-[var(--color-rule)] bg-[var(--color-paper-2)] text-[var(--color-ink)]",271 "shadow-[0_1px_0_rgba(255,255,255,0.6)_inset,0_1px_2px_rgba(0,0,0,0.04)]",272 "dark:shadow-[0_1px_0_rgba(255,255,255,0.04)_inset]",273 ),274 // Confirmed flash275 doneFlash &&276 (isDestructive277 ? "border-transparent bg-red-500 text-white shadow-none dark:bg-red-500"278 : "border-transparent bg-[var(--color-ink)] text-[var(--color-paper)] shadow-none"),279 className,280 )}281 >282 {/* Fill — clip-path, linear during hold */}283 {!reduce && (284 <motion.span285 aria-hidden286 className={cn(287 "pointer-events-none absolute inset-0 overflow-hidden",288 isDestructive289 ? "bg-gradient-to-r from-red-600 to-red-500 dark:from-red-500 dark:to-red-400"290 : "bg-[var(--color-ink)]",291 )}292 style={{ clipPath }}293 >294 <motion.span295 className="absolute inset-y-0 w-7 bg-gradient-to-r from-transparent to-white/30 dark:to-white/20"296 style={{ left: edgeLeft, opacity: edgeOpacity }}297 />298 </motion.span>299 )}300 301 <span className="relative z-[1] inline-flex items-center gap-2.5">302 {doneFlash ? (303 <motion.span304 aria-hidden305 className="inline-flex size-3.5 items-center justify-center"306 initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}307 animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}308 transition={{ type: "spring", duration: 0.3, bounce: 0 }}309 >310 <svg width="14" height="14" viewBox="0 0 24 24" fill="none">311 <motion.path312 d="M4 12.5L9.5 18L20 6"313 stroke="currentColor"314 strokeWidth={2.4}315 strokeLinecap="round"316 strokeLinejoin="round"317 initial={{ pathLength: 0 }}318 animate={{ pathLength: 1 }}319 transition={{ duration: 0.26, delay: 0.04, ease: EASE_OUT }}320 />321 </svg>322 </motion.span>323 ) : (324 resolvedIcon325 )}326 327 <span className="relative inline-grid place-items-center">328 {reduce ? (329 <span>{armed ? armedLabel : label}</span>330 ) : (331 <>332 {/* Sizing layer: box auto-fits the wider of the two possible labels */}333 <span aria-hidden className="invisible col-start-1 row-start-1">334 {label}335 </span>336 <span aria-hidden className="invisible col-start-1 row-start-1">337 Confirmed338 </span>339 <motion.span340 className="col-start-1 row-start-1"341 style={{ opacity: inkIdle, filter: labelFilter }}342 >343 {label}344 </motion.span>345 <motion.span346 aria-hidden347 className="col-start-1 row-start-1 text-white"348 style={{ opacity: inkReveal, filter: labelFilter }}349 >350 {doneFlash ? "Confirmed" : label}351 </motion.span>352 </>353 )}354 </span>355 356 {!reduce && (357 <motion.span358 className="min-w-[2.25ch] text-right font-mono text-[10px] tabular-nums tracking-wide text-current"359 style={{ opacity: pctOpacity }}360 >361 {pct}362 </motion.span>363 )}364 </span>365 366 <span className="sr-only" role="status" aria-live="polite">367 {announce}368 </span>369 </motion.button>370 );371}372 373function TrashIcon({ className }: { className?: string }) {374 return (375 <svg376 aria-hidden377 viewBox="0 0 24 24"378 fill="none"379 stroke="currentColor"380 strokeWidth="1.75"381 strokeLinecap="round"382 strokeLinejoin="round"383 className={className}384 >385 <path d="M4 7h16" />386 <path d="M10 11v6" />387 <path d="M14 11v6" />388 <path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12" />389 <path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />390 </svg>391 );392}
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.