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. The user times the
hold against the fill, so a non-linear fill would misreport the time left.
playback.current = animate(progress, 1, {
duration: duration / 1000,
ease: "linear",
onComplete: complete,
});Cancel vs confirm
Release before the fill completes and progress springs back instead of
jumping, with enough damping to settle without bouncing:
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, with no spring and no overshoot. The
fill already showed the effort; the check only confirms it.
<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 registers, then eases back to 0 over a quick 0.2s
(no spring here, since this is only a reset):
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, so a
keyboard hold runs the same state machine as a pointer hold:
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. The fill shows how much longer to hold, the spring shows the release registered, and the label stack keeps every state change free of 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. The user times the
hold against the fill, so a non-linear fill would misreport the time left.
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 springs back instead of
jumping, with enough damping to settle without bouncing:
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, with no spring and no overshoot. The
fill already showed the effort; the check only confirms it.
<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 registers, then eases back to 0 over a quick 0.2s
(no spring here, since this is only a reset):
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, so a
keyboard hold runs the same state machine as a pointer hold:
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. The fill shows how much longer to hold, the spring shows the release registered, and the label stack keeps every state change free of layout shift.
No reduced-motion branch
Unlike most GodUI motion, this component doesn't check
prefers-reduced-motion. The omission is deliberate. The fill
is direct, proportional feedback for a gesture the user is 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