HoldToConfirm

Press-and-hold destructive action button with a keyboard fallback.

import { HoldToConfirm }from "@/components/ui"

Hold to delete

Full source

The complete component, read from src/components/ui/hold-to-confirm.tsx.

hold-to-confirm.tsx
"use client";

import { useState, useRef, useEffect } from "react";
import { Trash2 } from "lucide-react";

interface HoldToConfirmProps {
  label?: string;
  duration?: number;
  onConfirm: () => void;
  onKeyboardFallback: () => void;
  disabled?: boolean;
}

export function HoldToConfirm({
  label = "Hold to delete",
  duration = 1200,
  onConfirm,
  onKeyboardFallback,
  disabled,
}: HoldToConfirmProps) {
  const [pct, setPct] = useState(0);
  const timer = useRef<ReturnType<typeof setInterval> | null>(null);
  const start = useRef(0);

  const stop = () => {
    if (timer.current) clearInterval(timer.current);
    timer.current = null;
    setPct(0);
  };

  const begin = () => {
    if (disabled || timer.current) return;
    start.current = Date.now();
    timer.current = setInterval(() => {
      const p = Math.min(100, ((Date.now() - start.current) / duration) * 100);
      setPct(p);
      if (p >= 100) { stop(); onConfirm(); }
    }, 16);
  };

  useEffect(() => () => { if (timer.current) clearInterval(timer.current); }, []);

  return (
    <button
      disabled={disabled}
      onPointerDown={begin}
      onPointerUp={stop}
      onPointerLeave={stop}
      onPointerCancel={stop}
      onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onKeyboardFallback(); } }}
      aria-label={`${label}. Press Enter for a confirmation dialog instead.`}
      className="relative inline-flex h-10 select-none items-center justify-center gap-1.5 overflow-hidden rounded-lg bg-error px-4 text-sm font-medium text-on-error shadow-sm transition-[color,background-color,box-shadow] hover:bg-error-hover hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-error focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-45 disabled:shadow-none"
      style={{ touchAction: "none" }}
    >
      <span
        className="absolute inset-y-0 left-0 bg-black/30 transition-none"
        style={{ width: `${pct}%` }}
        aria-hidden
      />
      <span className="relative inline-flex items-center gap-1.5">
        <Trash2 size={15} aria-hidden />
        {pct > 0 ? "Keep holding\u2026" : label}
      </span>
    </button>
  );
}