Hold to Confirm

Destructive button that fires only after a press-and-hold.

Press and hold (or hold Space)

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/hold-to-confirm.tsx

    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>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.