HoldToConfirm

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

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

Specification

Requires a deliberate press-and-hold to trigger a destructive action, with a keyboard fallback to a confirmation dialog.

Props (5)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
labelstring"Hold to delete"Idle button label describing the action.
durationnumber1200Milliseconds the user must hold before the action fires.
onConfirm*() => void—Called once the hold completes and the progress bar fills.
onKeyboardFallback*() => void—Called on Enter/Space so keyboard users get a confirmation dialog instead of holding.
disabledboolean—Disables the button and prevents holding.

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>
  );
}