Hold to confirm
Pressing and holding fills the button from left to right; let go early and it drains, finish and it confirms.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const DURATION = 1200;
const KEYS = [" ", "Enter"];
function Content({ done }: { done: boolean }) {
return (
<span className={styles.content}>
<svg className={styles.icon} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
{done ? <path d="M3.5 8.5 6.5 11.5 12.5 4.5" /> : <path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" />}
</svg>
{done ? "Deleted" : "Hold to delete"}
</span>
);
}
export default function Demo() {
const button = useRef<HTMLButtonElement>(null);
const progress = useRef(0);
const holding = useRef(false);
const raf = useRef(0);
const last = useRef(0);
const [done, setDone] = useState(false);
const reduced = usePrefersReducedMotion();
const hint = useId();
const paint = () => button.current?.style.setProperty("--p", progress.current.toFixed(4));
const tick = (now: number) => {
const dt = now - last.current;
last.current = now;
// Fill at the hold speed, drain three times faster.
progress.current = Math.min(1, Math.max(0, progress.current + (holding.current ? dt / DURATION : (-dt * 3) / DURATION)));
paint();
if (progress.current >= 1) {
holding.current = false;
raf.current = 0;
setDone(true);
} else if (progress.current <= 0 && !holding.current) {
raf.current = 0;
} else {
raf.current = requestAnimationFrame(tick);
}
};
const start = () => {
if (done) return;
holding.current = true;
if (!raf.current) {
last.current = performance.now();
raf.current = requestAnimationFrame(tick);
}
};
const stop = () => {
holding.current = false;
if (reduced && !done) {
progress.current = 0;
paint();
}
};
useEffect(() => () => cancelAnimationFrame(raf.current), []);
useEffect(() => {
if (!done) return;
const timer = setTimeout(() => {
progress.current = 0;
paint();
setDone(false);
}, 2200);
return () => clearTimeout(timer);
}, [done]);
return (
<div className={styles.root}>
<div className={styles.stack}>
<button
ref={button}
type="button"
className={`${styles.button} ${done ? styles.done : ""}`}
aria-disabled={done}
aria-describedby={hint}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
start();
}}
onPointerUp={stop}
onPointerCancel={stop}
onLostPointerCapture={stop}
onKeyDown={(event) => {
if (!KEYS.includes(event.key)) return;
event.preventDefault();
if (!event.repeat) start();
}}
onKeyUp={(event) => KEYS.includes(event.key) && stop()}
onBlur={stop}
onContextMenu={(event) => event.preventDefault()}
>
<Content done={done} />
<span className={styles.fill} aria-hidden="true">
<Content done={done} />
</span>
</button>
<p id={hint} className={styles.note}>Removes Villa Azul and its 3 drafts</p>
<span className={styles.sr} aria-live="polite">{done ? "Listing deleted" : ""}</span>
</div>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.stack { display: grid; justify-items: center; gap: 12px; }
.button {
--p: 0;
position: relative; overflow: hidden; display: grid;
min-height: 52px; min-width: 210px; padding: 0 28px; border-radius: 999px; border: 1.5px solid var(--ink);
background: var(--white); color: var(--ink); font-size: 15px; font-weight: 600; cursor: pointer;
user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none;
transition: transform .2s var(--out);
}
.button:active { transform: scale(.98); }
.content { display: inline-flex; align-items: center; justify-content: center; gap: 10px; white-space: nowrap; }
.icon { flex: none; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.fill {
position: absolute; inset: 0; display: grid; place-items: center; padding: 0 28px;
background: var(--ink); color: var(--white);
clip-path: inset(0 calc(100% - var(--p) * 100%) 0 0);
}
.done { border-color: var(--accent-text); cursor: default; transform: none; }
.done .fill { background: var(--accent-text); clip-path: none; }
.done .fill .content { animation: pop .4s var(--out); }
@keyframes pop { from { transform: scale(.85); opacity: 0; } }
.note { font-size: 13px; color: var(--muted); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.done .fill .content { animation: none; }
}
Use it for
- Destructive actions like delete or cancel booking, instead of a confirm dialog
- Actions on touch devices that are easy to trigger by accident
Avoid
- Frequent everyday actions, since the hold adds a second each time
- Users with motor impairments as the only path, so keep an undo or a dialog fallback
How it works
Pointerdown captures the pointer and starts a requestAnimationFrame loop that adds elapsed time divided by 1200ms to a progress ref, or drains it three times faster once released. Each frame writes the value to a --p custom property. A second copy of the label in inverted colours sits on top and is revealed with clip-path inset, so the text changes colour exactly at the fill edge. Reaching 1 switches to the Deleted state, which resets after a pause.
Accessibility
Holding Space or Enter fills it the same way, with key repeat ignored, and blur cancels. The hint text is linked with aria-describedby and a live region announces the deletion. Reduced motion keeps the progress fill (it is the feedback) but drops the drain and the pop.