## Hold to Confirm — Action Feedback A guard rail in front of destructive actions. Docs: https://www.interior.dev/docs/hold-to-confirm Reference: https://www.interior.dev/reference/hold-to-confirm License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/hold-to-confirm.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { HoldToConfirm } from "@/components/interior/hold-to-confirm"; export function DangerZone({ workspaceId }: { workspaceId: string }) { const router = useRouter(); const [pending, setPending] = useState(false); return (

Delete this workspace

Members, files and history go with it. There is no undo.

track("workspace.delete.abandoned", { workspaceId })} onConfirm={async () => { setPending(true); await fetch(`/api/workspaces/${workspaceId}`, { method: "DELETE" }); router.push("/workspaces"); }} > Delete workspace
); } ``` ### Props - `onConfirm` (`() => void`) Fires once, only when the hold reaches full duration. A click never reaches it. - `children` (`React.ReactNode`) The resting label. It stays the button's accessible name in every state. - `onAbort` (`() => void`) — default: `undefined` Fires the moment a hold is released early, including a stray click. Useful for measuring how often people almost destroyed something. - `confirmLabel` (`string`) — default: `"Confirmed"` Shown after commit and announced once through a polite live region. - `duration` (`number`) — default: `1800` Milliseconds of continuous hold required. Also the number spoken in the screen reader hint. - `resetAfter` (`number`) — default: `1600` Milliseconds the confirmed state is held before the button returns to rest. Set to 0 to keep it confirmed and reset it yourself. - `steps` (`number`) — default: `20` Render budget for the hold. Progress is sampled this many times, and the sweep runs as one continuous animation independent of them. - `releaseRate` (`number`) — default: `2.5` How many times faster progress drains than it fills when you let go. Re-pressing mid-drain resumes from what is left. - `disabled` (`boolean`) — default: `false` Marked with aria-disabled rather than the disabled attribute, so focus is never dropped to the body mid-hold. - `className` (`string`) — default: `""` Appended last, so the button's surface, radius and width are overridable from outside. ### Behavior notes - A click cannot confirm: the click event is prevented at every stage, so a mis-aimed pointer, a double-click on the row underneath, or a stray Enter on a focused button destroys nothing. - Releasing early does not snap the progress to zero, it drains at a bounded rate, and pressing again resumes from whatever is left rather than restarting the count. - The label does not change while you hold. A block sweeps across the button and the same text inverts inside it, so the only thing moving is the progress itself, and the button never changes width. Layout never reflows when the state changes. - Progress arrives as twenty discrete steps rather than a float, so a 1.2 second hold costs twenty renders instead of eighty, and no React state is written per animation frame. - Losing the window, hiding the tab, dragging past the move tolerance, or blurring the button all release the hold, so a hold can never survive in the background and fire when nobody is watching. - Screen readers get a static hint naming the required hold time and one polite announcement at commit, never a stream of progress updates, and prefers-reduced-motion removes the springs while leaving the hold itself intact, because the delay is the guard rail and not decoration. ### Source (`components/interior/hold-to-confirm.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { animate, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; const FACE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; export type HoldPhase = "idle" | "holding" | "releasing" | "committed"; export type UseHoldToConfirmOptions = { onConfirm: () => void; onAbort?: () => void; duration?: number; steps?: number; releaseRate?: number; moveTolerance?: number; haptic?: boolean; disabled?: boolean; }; export function useHoldToConfirm({ onConfirm, onAbort, duration = 1800, steps = 20, releaseRate = 2.5, moveTolerance = 10, haptic = true, disabled = false, }: UseHoldToConfirmOptions) { const [step, setStep] = useState(0); const [phase, setPhase] = useState("idle"); const phaseRef = useRef("idle"); const down = useRef(false); const elapsed = useRef(0); const last = useRef(0); const raf = useRef(0); const origin = useRef<{ x: number; y: number } | null>(null); const confirm = useRef(onConfirm); confirm.current = onConfirm; const abort = useRef(onAbort); abort.current = onAbort; const move = useCallback((next: HoldPhase) => { phaseRef.current = next; setPhase(next); }, []); const reset = useCallback(() => { cancelAnimationFrame(raf.current); raf.current = 0; down.current = false; elapsed.current = 0; origin.current = null; setStep(0); move("idle"); }, [move]); const begin = useCallback( (point?: { x: number; y: number }) => { if (disabled) return; if (phaseRef.current === "committed" || phaseRef.current === "holding") { return; } origin.current = point ?? null; down.current = true; move("holding"); if (raf.current) return; last.current = performance.now(); const loop = (now: number) => { const dt = Math.min(64, now - last.current); last.current = now; elapsed.current += down.current ? dt : -dt * releaseRate; if (elapsed.current >= duration) { raf.current = 0; elapsed.current = duration; down.current = false; origin.current = null; setStep(steps); move("committed"); if (haptic) navigator.vibrate?.(14); confirm.current(); return; } if (elapsed.current <= 0) { raf.current = 0; elapsed.current = 0; origin.current = null; setStep(0); move("idle"); return; } const s = Math.min( steps, Math.floor((elapsed.current / duration) * steps), ); setStep((prev) => (prev === s ? prev : s)); raf.current = requestAnimationFrame(loop); }; raf.current = requestAnimationFrame(loop); }, [disabled, duration, steps, releaseRate, haptic, move], ); const release = useCallback(() => { if (phaseRef.current !== "holding") return; down.current = false; origin.current = null; move("releasing"); abort.current?.(); }, [move]); useEffect(() => { const bail = () => release(); const onVisibility = () => { if (document.hidden) release(); }; window.addEventListener("blur", bail); document.addEventListener("visibilitychange", onVisibility); return () => { window.removeEventListener("blur", bail); document.removeEventListener("visibilitychange", onVisibility); cancelAnimationFrame(raf.current); raf.current = 0; }; }, [release]); const bind = { onPointerDown: (e: React.PointerEvent) => { if (e.pointerType === "mouse" && e.button !== 0) return; e.currentTarget.setPointerCapture?.(e.pointerId); begin({ x: e.clientX, y: e.clientY }); }, onPointerMove: (e: React.PointerEvent) => { const from = origin.current; if (phaseRef.current !== "holding" || !from) return; if (Math.hypot(e.clientX - from.x, e.clientY - from.y) > moveTolerance) { release(); } }, onPointerUp: release, onPointerCancel: release, onPointerLeave: release, onKeyDown: (e: React.KeyboardEvent) => { if (e.key === "Escape") { if (phaseRef.current === "holding" || phaseRef.current === "releasing") { e.preventDefault(); reset(); } return; } if (e.repeat) return; if (e.key === " " || e.key === "Enter") { e.preventDefault(); begin(); } }, onKeyUp: (e: React.KeyboardEvent) => { if (e.key === " " || e.key === "Enter") release(); }, onBlur: release, onClick: (e: React.MouseEvent) => { e.preventDefault(); if (phaseRef.current === "committed") e.stopPropagation(); }, onContextMenu: (e: React.MouseEvent) => e.preventDefault(), }; return { bind, step, steps, phase, progress: step / steps, reset, }; } export type HoldToConfirmProps = { onConfirm: () => void; children: React.ReactNode; onAbort?: () => void; confirmLabel?: string; duration?: number; resetAfter?: number; steps?: number; releaseRate?: number; disabled?: boolean; className?: string; }; export function HoldToConfirm({ onConfirm, children, onAbort, confirmLabel = "Confirmed", duration = 1800, resetAfter = 1600, steps = 20, releaseRate = 2.5, disabled = false, className = "", }: HoldToConfirmProps) { const { bind, phase, reset } = useHoldToConfirm({ onConfirm, onAbort, duration, steps, releaseRate, disabled, }); const reduced = useReducedMotion(); const hintId = useId(); const committed = phase === "committed"; const seconds = Math.round(duration / 100) / 10; const swept = useMotionValue(0); const clipPath = useTransform( swept, (v) => `inset(0 ${(1 - v) * 100}% 0 0)`, ); useEffect(() => { if (phase !== "committed" || resetAfter <= 0) return; const back = setTimeout(reset, resetAfter); return () => clearTimeout(back); }, [phase, resetAfter, reset]); useEffect(() => { if (reduced) { swept.set(phase === "holding" || phase === "committed" ? 1 : 0); return; } if (phase === "committed") { const controls = animate(swept, 1, { duration: 0.12, ease: "linear" }); return () => controls.stop(); } const from = swept.get(); if (phase === "holding") { const controls = animate(swept, 1, { duration: (duration * (1 - from)) / 1000, ease: "linear", }); return () => controls.stop(); } const controls = animate(swept, 0, { duration: (duration * from) / releaseRate / 1000, ease: [0.23, 1, 0.32, 1], }); return () => controls.stop(); }, [phase, duration, releaseRate, reduced, swept]); return ( ); } function Faces({ committed, confirmLabel, children, }: { committed: boolean; confirmLabel: string; children: React.ReactNode; }) { return ( {children} {confirmLabel} ); } ```