Destructive Hold to Confirm
Holding fills the control at an honest rate; letting go early drains it back several times faster.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";
/**
* Vibary · Destructive Hold to Confirm
*
* A delete that asks for a second of deliberate pressure instead of a
* second dialog. Holding fills the control at a steady, honest rate;
* letting go early drains it back several times faster, so an accidental
* press visibly un-happens rather than trailing off. The asymmetry is the
* whole design: committing is slow, aborting is instant.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the control reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `label`, `holdMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type DestructiveHoldFillProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Resting label. */
label?: string;
/** Label while the control is being held. */
holdingLabel?: string;
/** Label once the hold completes. */
doneLabel?: string;
/** Line under the control explaining the gesture. */
hint?: string;
/** How long the hold has to last. Overrides the variant when set. */
holdMs?: number;
/** How long the confirmed state is held before returning to rest, in ms. */
resetMs?: number;
/** Width — px number or any CSS length. */
width?: number | string;
/** Fires when the hold completes. */
onConfirm?: () => void;
/** Fires when a hold is abandoned before it completes. */
onAbort?: () => void;
};
type VariantConfig = {
/** Seconds a full hold takes. */
fillSeconds: number;
/** How many times faster the drain runs than the fill. */
drainFactor: number;
/** Spring the confirmed state arrives on. */
spring: { type: "spring"; stiffness: number; damping: number };
};
// The confirmed state lands on a spring that settles once — ζ = damping /
// 2√stiffness stays at or above 0.89 — because the word underneath it is
// the answer to "did I just delete something". Variants change how long
// the commitment takes; every one of them drains at least 2.6× faster
// than it fills, since that ratio is what makes an abort feel safe.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short, unfussy second. For a control that removes one row.
subtle: {
fillSeconds: 1,
drainFactor: 2.6,
spring: { type: "spring", stiffness: 520, damping: 46 },
},
// The all-purpose setting.
default: {
fillSeconds: 1.4,
drainFactor: 3.2,
spring: { type: "spring", stiffness: 420, damping: 39 },
},
// A longer, more deliberate hold — for something that cannot be undone
// at all, like emptying a workspace.
playful: {
fillSeconds: 1.9,
drainFactor: 3.6,
spring: { type: "spring", stiffness: 340, damping: 33 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one.
* The two semantic colors stay literal — they carry meaning. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DANGER = "#E5484D";
const DONE = "#10B981";
const RADIUS = 10;
type Phase = "idle" | "holding" | "done";
export default function DestructiveHoldFill({
variant = "default",
label = "Hold to delete workspace",
holdingLabel = "Keep holding",
doneLabel = "Workspace deleted",
hint = "Press and hold. Let go at any point to cancel.",
holdMs,
resetMs = 2200,
width = 300,
onConfirm,
onAbort,
}: DestructiveHoldFillProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const fillSeconds = holdMs !== undefined ? holdMs / 1000 : cfg.fillSeconds;
const [phase, setPhase] = useState<Phase>("idle");
// 0 → 1. The bar is a scaleX off this value, so the fill is composited
// rather than relaid out, and the drain can read the exact point the
// hold was abandoned at.
const progress = useMotionValue(0);
const running = useRef<{ stop: () => void } | null>(null);
const held = useRef(false);
const callbacks = useRef({ onConfirm, onAbort });
useEffect(() => {
callbacks.current = { onConfirm, onAbort };
}, [onConfirm, onAbort]);
useEffect(() => () => running.current?.stop(), []);
useEffect(() => {
if (phase !== "done") return;
const timer = setTimeout(() => {
// Reset on the same drain the abort uses, so the control never
// snaps back to empty in a single frame.
running.current = animate(progress, 0, { duration: 0.3, ease: "easeOut" });
setPhase("idle");
}, resetMs);
return () => clearTimeout(timer);
}, [phase, resetMs, progress]);
const start = () => {
if (phase !== "idle" || held.current) return;
held.current = true;
setPhase("holding");
running.current?.stop();
// Linear on the way in: the rate has to be honest, because the only
// thing the fill promises is how much longer this will take.
running.current = animate(progress, 1, {
duration: fillSeconds,
ease: "linear",
onComplete: () => {
if (!held.current) return;
held.current = false;
setPhase("done");
callbacks.current.onConfirm?.();
},
});
};
const release = () => {
if (!held.current) return;
held.current = false;
running.current?.stop();
const reached = progress.get();
setPhase("idle");
if (reached <= 0) return;
callbacks.current.onAbort?.();
// The drain is proportional to how far the hold got, at several times
// the fill rate, and eased out so it leaves quickest at the start.
// Anything slower reads as the gesture still deciding.
running.current = animate(progress, 0, {
duration: (reached * fillSeconds) / cfg.drainFactor,
ease: "easeOut",
});
};
const isDone = phase === "done";
const currentLabel = isDone
? doneLabel
: phase === "holding"
? holdingLabel
: label;
return (
<div style={{ width }}>
<button
type="button"
onPointerDown={start}
onPointerUp={release}
onPointerLeave={release}
onPointerCancel={release}
// The keyboard gets the same contract: hold the key, not tap it.
onKeyDown={(event) => {
if (event.repeat) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
start();
}
}}
onKeyUp={(event) => {
if (event.key === " " || event.key === "Enter") release();
}}
onBlur={release}
disabled={isDone}
style={{
position: "relative",
display: "block",
width: "100%",
height: 40,
padding: 0,
borderRadius: RADIUS,
border: `1px solid ${
isDone
? `color-mix(in srgb, ${DONE} 40%, transparent)`
: `color-mix(in srgb, ${DANGER} 34%, transparent)`
}`,
transition: "border-color 220ms ease-out",
background: tone(4),
color: "inherit",
font: "inherit",
overflow: "hidden",
cursor: isDone ? "default" : "pointer",
touchAction: "none",
WebkitTapHighlightColor: "transparent",
}}
>
{/* The fill. scaleX off the left edge is a transform, so holding
costs nothing per frame; the same motion value is what the
drain reads to know how far the gesture got. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: isDone ? 0 : 1 }}
transition={{ duration: 0.26, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
transformOrigin: "left center",
scaleX: progress,
background: `color-mix(in srgb, ${DANGER} 22%, transparent)`,
}}
/>
{/* Confirmed ground is a second layer crossfading over the first,
because a color-mix() value cannot be interpolated by an
animation — only faded between. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: isDone ? 1 : 0 }}
transition={{ duration: 0.26, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
background: `color-mix(in srgb, ${DONE} 16%, transparent)`,
}}
/>
<span
style={{
position: "relative",
display: "grid",
placeItems: "center",
height: "100%",
fontSize: 13,
fontWeight: 650,
}}
>
<AnimatePresence initial={false}>
<motion.span
key={currentLabel}
// The word changes by crossfade at one constant size. Text
// that scales into a confirmation is text you have to read
// twice, which is the last thing a delete needs.
initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
transition={{
opacity: { duration: 0.16, ease: "easeOut" },
y: cfg.spring,
}}
style={{
gridArea: "1 / 1",
display: "inline-flex",
alignItems: "center",
gap: 7,
color: isDone ? DONE : DANGER,
whiteSpace: "nowrap",
}}
>
{isDone ? (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<motion.path
d="M3.4 8.4 6.3 11.3 12.6 5"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: reduceMotion ? 0 : 0.26, ease: "easeOut" }}
/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M3 4.5h10M6.4 4.5V3.2h3.2v1.3M4.4 4.5l.6 8.1h6l.6-8.1"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
{currentLabel}
</motion.span>
</AnimatePresence>
</span>
</button>
<div
style={{
marginTop: 8,
fontSize: 11.5,
opacity: 0.5,
lineHeight: 1.45,
textAlign: "center",
}}
>
{hint}
</div>
<span
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{isDone ? doneLabel : ""}
</span>
</div>
);
}About this pattern
A delete that asks for a second of deliberate pressure instead of a second dialog. The fill runs linear while the control is held, because the only thing it promises is how much longer this will take, and the rate has to be honest about that. The craft is in the release: an abandoned hold drains from wherever it got to at roughly three times the fill rate, eased out so it leaves quickest at the start. That asymmetry is the entire design — committing is slow and abortable, backing out is immediate and unmistakable, so a press made by accident visibly un-happens. The keyboard gets the same contract rather than a tap-equivalent, the label crossfades at one constant size, and the confirmed ground is a second tint layer fading over the first, since a color-mix value can be faded between but never interpolated.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Settings
Holding a button to commit, with the ring emptying again the moment it is released.
Related patterns
- Delete Confirm MorphA delete control widens in place into a question with a way out, instead of opening a dialog.
- Form Submit ProgressSubmit narrows to a turning disc while the request runs, then opens out as the confirmed state.
- Processing Steps CheckThree stages clear across a horizontal rail, each hand-off filling the segment to the next marker.