Anatomy of the Hold Confirm Button
How a press-and-hold gesture replaces a confirmation dialog — one motion value, a linear fill, a spring cancel, and a label stack that never resizes the button.
↓Scroll to step through it
The fill
Everything rides on a single useMotionValue(0) called progress. It never
touches layout — it drives the scaleX of an absolutely-positioned span
pinned to the button's own bounds, transform-origin: left. The button
never resizes; only the fill's transform changes.
On pointer/key down, progress animates 0 → 1 over duration (default
900ms) with ease: "linear" — no acceleration, no easing curve. A hold is
a countdown the user is timing themselves against; a non-linear fill would
make that countdown lie.
playback.current = animate(progress, 1, {
duration: duration / 1000,
ease: "linear",
onComplete: complete,
});Cancel vs confirm
Release before the fill completes and progress doesn't just jump back —
it springs, with enough damping to feel decisive rather than bouncy:
stiffness: 320, damping: 32, mass: 0.9. Hold all the way through and
the fill locks at 1, a checkmark draws itself in over the label, and
onConfirm fires.
The check draws with the same restraint as the fill: an SVG path animates
pathLength from 0 to 1 over a flat 0.3s, no spring, no overshoot. The
fill already did the "effort" beat — the check is just confirmation, so it
stays quiet.
<motion.path
d="M5 12.5 10 17.5 19 7"
initial={{ pathLength: 0 }}
animate={active ? { pathLength: 1 } : { pathLength: 0 }}
transition={{ duration: 0.3 }}
/>idle, holding, and confirmed each have their own label, and all three
occupy the same grid cell:
<span className="relative grid">
{labels.map(({ key, node }) => {
const active = status === key;
return (
<span
key={key}
aria-hidden={active ? undefined : "true"}
className={`col-start-1 row-start-1 ... ${active ? "" : "invisible"}`}
>
{node}
</span>
);
})}
</span>A CSS grid track sizes to the largest child even when that child is
invisible rather than display: none — invisible elements still occupy
their box, they just don't paint. So the grid cell is always as wide as the
longest of the three labels, and the button's width is locked in before any
status change. Swap holdingLabel for something long and the button doesn't
twitch when the hold starts; it was already sized for it. The inactive
labels also carry aria-hidden, so screen readers only ever see the one
that's showing.
Confirming doesn't reset immediately — the filled bar holds for 1100ms so
the "done" state actually registers, then eases back to 0 over a quick
0.2s (no spring here; the moment has passed, this is just tidying up):
const complete = () => {
if (!mounted.current) return;
setStatus("confirmed");
onConfirm?.();
resetTimer.current = setTimeout(() => {
if (!mounted.current) return;
setStatus("idle");
animate(progress, 0, { duration: 0.2 });
}, 1100);
};Both the pending animate() playback and this timeout are torn down on
unmount, so a hold that outlives its button never calls setState into the
void.
onPointerDown / onPointerUp / onPointerLeave / onPointerCancel all
map to the same start() / cancel() pair that Enter and Space use — a
keyboard hold is not a lesser affordance, it's the identical state machine:
const handleKeyDown = (event) => {
if ((event.key === " " || event.key === "Enter") && !event.repeat) {
event.preventDefault();
start();
}
};
const handleKeyUp = (event) => {
if (event.key === " " || event.key === "Enter") cancel();
};!event.repeat matters: holding a key fires a stream of keydown events at
the OS repeat rate, and without that guard every one of them would call
start() again and re-stomp the running animation.
The result
One motion value, two easings, and a grid trick — that's the entire mechanism. The fill tells you how much longer, the spring tells you it heard you let go, and the label stack means none of it ever costs you a layout shift.
- Button surface
- the real destructive fill color
- Progress fill
- scaleX(progress), origin-left
<motion.spanaria-hidden="true"style={{ scaleX: progress }}className="absolute inset-0 origin-left bg-black/20"/>Anatomy of the Hold Confirm Button
How a press-and-hold gesture replaces a confirmation dialog — one motion value, a linear fill, a spring cancel, and a label stack that never resizes the button.
- Button surface
- the real destructive fill color
- Progress fill
- scaleX(progress), origin-left
<motion.spanaria-hidden="true"style={{ scaleX: progress }}className="absolute inset-0 origin-left bg-black/20"/>The fill
Everything rides on a single useMotionValue(0) called progress. It never
touches layout — it drives the scaleX of an absolutely-positioned span
pinned to the button's own bounds, transform-origin: left. The button
never resizes; only the fill's transform changes.
On pointer/key down, progress animates 0 → 1 over duration (default
900ms) with ease: "linear" — no acceleration, no easing curve. A hold is
a countdown the user is timing themselves against; a non-linear fill would
make that countdown lie.
playback.current = animate(progress, 1, {
duration: duration / 1000,
ease: "linear",
onComplete: complete,
});released early
held to 900ms
- Cancel
- spring(320, 32, 0.9) back to 0
- Confirm
- linear to 1, then check draws in 0.3s
const cancel = () => {if (statusRef.current !== "holding") return;playback.current?.stop();setStatus("idle");animate(progress, 0, { type: "spring", stiffness: 320, damping: 32, mass: 0.9,});};Cancel vs confirm
Release before the fill completes and progress doesn't just jump back —
it springs, with enough damping to feel decisive rather than bouncy:
stiffness: 320, damping: 32, mass: 0.9. Hold all the way through and
the fill locks at 1, a checkmark draws itself in over the label, and
onConfirm fires.
The check draws with the same restraint as the fill: an SVG path animates
pathLength from 0 to 1 over a flat 0.3s, no spring, no overshoot. The
fill already did the "effort" beat — the check is just confirmation, so it
stays quiet.
<motion.path
d="M5 12.5 10 17.5 19 7"
initial={{ pathLength: 0 }}
animate={active ? { pathLength: 1 } : { pathLength: 0 }}
transition={{ duration: 0.3 }}
/>idle, holding, and confirmed each have their own label, and all three
occupy the same grid cell:
<span className="relative grid">
{labels.map(({ key, node }) => {
const active = status === key;
return (
<span
key={key}
aria-hidden={active ? undefined : "true"}
className={`col-start-1 row-start-1 ... ${active ? "" : "invisible"}`}
>
{node}
</span>
);
})}
</span>A CSS grid track sizes to the largest child even when that child is
invisible rather than display: none — invisible elements still occupy
their box, they just don't paint. So the grid cell is always as wide as the
longest of the three labels, and the button's width is locked in before any
status change. Swap holdingLabel for something long and the button doesn't
twitch when the hold starts; it was already sized for it. The inactive
labels also carry aria-hidden, so screen readers only ever see the one
that's showing.
Confirming doesn't reset immediately — the filled bar holds for 1100ms so
the "done" state actually registers, then eases back to 0 over a quick
0.2s (no spring here; the moment has passed, this is just tidying up):
const complete = () => {
if (!mounted.current) return;
setStatus("confirmed");
onConfirm?.();
resetTimer.current = setTimeout(() => {
if (!mounted.current) return;
setStatus("idle");
animate(progress, 0, { duration: 0.2 });
}, 1100);
};Both the pending animate() playback and this timeout are torn down on
unmount, so a hold that outlives its button never calls setState into the
void.
onPointerDown / onPointerUp / onPointerLeave / onPointerCancel all
map to the same start() / cancel() pair that Enter and Space use — a
keyboard hold is not a lesser affordance, it's the identical state machine:
const handleKeyDown = (event) => {
if ((event.key === " " || event.key === "Enter") && !event.repeat) {
event.preventDefault();
start();
}
};
const handleKeyUp = (event) => {
if (event.key === " " || event.key === "Enter") cancel();
};!event.repeat matters: holding a key fires a stream of keydown events at
the OS repeat rate, and without that guard every one of them would call
start() again and re-stomp the running animation.
The result
One motion value, two easings, and a grid trick — that's the entire mechanism. The fill tells you how much longer, the spring tells you it heard you let go, and the label stack means none of it ever costs you a layout shift.
No reduced-motion branch — and that's fine here
Unlike most Myndui motion, this component doesn't check
prefers-reduced-motion. That's a deliberate omission, not an oversight: the
fill isn't a decorative flourish playing at the user, it's the direct,
proportional feedback for a gesture they are actively performing and can
stop at any instant. Suppressing it would remove the only signal that tells
someone how much longer to keep holding.
Motion Score
scaleScale spring / pressopacityFade / cross-fadepathLengthSVG pathLength reveal