Hold to Confirm
Destructive button that fires only after a press-and-hold.
Press and hold (or hold Space)
components/ui/hold-to-confirm.tsx
"use client";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { Check, Trash2 } from "lucide-react";
import { useRef, useState } from "react";
type HoldToConfirmProps = {
onConfirm: () => void;
label?: string;
/** Seconds to hold */
duration?: number;
};
/** Destructive action that only fires after holding — prevents accidental clicks. */
export function HoldToConfirm({ onConfirm, label = "Hold to delete", duration = 1.2 }: HoldToConfirmProps) {
const progress = useMotionValue(0);
const width = useTransform(progress, (v) => `${v * 100}%`);
const controls = useRef<ReturnType<typeof animate> | null>(null);
const [done, setDone] = useState(false);
function start() {
if (done) return;
controls.current?.stop();
controls.current = animate(progress, 1, {
duration: duration * (1 - progress.get()),
ease: "linear",
onComplete: () => {
setDone(true);
onConfirm();
setTimeout(() => {
setDone(false);
progress.set(0);
}, 1800);
},
});
}
function cancel() {
if (done) return;
controls.current?.stop();
controls.current = animate(progress, 0, { duration: 0.3 });
}
return (
<button
onPointerDown={start}
onPointerUp={cancel}
onPointerLeave={cancel}
onKeyDown={(e) => (e.key === " " || e.key === "Enter") && !e.repeat && start()}
onKeyUp={(e) => (e.key === " " || e.key === "Enter") && cancel()}
className={`relative inline-flex select-none items-center gap-2 overflow-hidden rounded-xl border px-5 py-3 text-sm font-medium transition-colors ${
done ? "border-emerald-600 bg-emerald-600 text-white" : "border-rose-200 bg-white text-rose-600"
}`}
>
{!done && <motion.span aria-hidden className="absolute inset-y-0 left-0 bg-rose-100" style={{ width }} />}
<span className="relative flex items-center gap-2">
{done ? <Check className="size-4" /> : <Trash2 className="size-4" />}
{done ? "Deleted" : label}
</span>
</button>
);
}
export default function Demo() {
return (
<div className="text-center">
<HoldToConfirm onConfirm={() => {}} />
<p className="mt-3 text-xs text-zinc-400">Press and hold (or hold Space)</p>
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/hold-to-confirm.tsxcomponents/ui/hold-to-confirm.tsx"use client"; import { animate, motion, useMotionValue, useTransform } from "motion/react"; import { Check, Trash2 } from "lucide-react"; import { useRef, useState } from "react"; type HoldToConfirmProps = { onConfirm: () => void; label?: string; /** Seconds to hold */ duration?: number; }; /** Destructive action that only fires after holding — prevents accidental clicks. */ export function HoldToConfirm({ onConfirm, label = "Hold to delete", duration = 1.2 }: HoldToConfirmProps) { const progress = useMotionValue(0); const width = useTransform(progress, (v) => `${v * 100}%`); const controls = useRef<ReturnType<typeof animate> | null>(null); const [done, setDone] = useState(false); function start() { if (done) return; controls.current?.stop(); controls.current = animate(progress, 1, { duration: duration * (1 - progress.get()), ease: "linear", onComplete: () => { setDone(true); onConfirm(); setTimeout(() => { setDone(false); progress.set(0); }, 1800); }, }); } function cancel() { if (done) return; controls.current?.stop(); controls.current = animate(progress, 0, { duration: 0.3 }); } return ( <button onPointerDown={start} onPointerUp={cancel} onPointerLeave={cancel} onKeyDown={(e) => (e.key === " " || e.key === "Enter") && !e.repeat && start()} onKeyUp={(e) => (e.key === " " || e.key === "Enter") && cancel()} className={`relative inline-flex select-none items-center gap-2 overflow-hidden rounded-xl border px-5 py-3 text-sm font-medium transition-colors ${ done ? "border-emerald-600 bg-emerald-600 text-white" : "border-rose-200 bg-white text-rose-600" }`} > {!done && <motion.span aria-hidden className="absolute inset-y-0 left-0 bg-rose-100" style={{ width }} />} <span className="relative flex items-center gap-2"> {done ? <Check className="size-4" /> : <Trash2 className="size-4" />} {done ? "Deleted" : label} </span> </button> ); } export default function Demo() { return ( <div className="text-center"> <HoldToConfirm onConfirm={() => {}} /> <p className="mt-3 text-xs text-zinc-400">Press and hold (or hold Space)</p> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.