ConfirmDialog

Modal confirmation with simple, type-to-confirm and hold-to-confirm modes.

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

Hold & type to confirm

Full source

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

confirm-dialog.tsx
"use client";

import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { controlBase, controlClasses } from "./field";
import { Button } from "./button";
import { HoldToConfirm } from "./hold-to-confirm";

interface ConfirmDialogProps {
  open: boolean;
  tone?: "default" | "destructive";
  title: string;
  body?: string;
  consequences?: string[];
  confirmLabel: string;
  mode?: "simple" | "type" | "hold";
  typeToMatch?: string;
  onConfirm: () => void;
  onCancel: () => void;
  loading?: boolean;
}

export function ConfirmDialog({
  open,
  tone = "default",
  title,
  body,
  consequences = [],
  confirmLabel,
  mode = "simple",
  typeToMatch,
  onConfirm,
  onCancel,
  loading,
}: ConfirmDialogProps) {
  const [typed, setTyped] = useState("");
  useEffect(() => { if (!open) setTyped(""); }, [open]);
  if (!open) return null;

  const destructive = tone === "destructive";
  const blocked = mode === "type" && typed.trim() !== typeToMatch;

  return (
    <div
      role="dialog"
      aria-modal="true"
      className="fixed inset-0 z-50 flex items-center justify-center bg-overlay p-4"
      onClick={onCancel}
    >
      <div onClick={(e) => e.stopPropagation()} className="w-full max-w-md overflow-hidden rounded-xl bg-surface shadow-overlay">
        <div className="px-5 pb-4 pt-5">
          <h3 className="text-base font-semibold text-text">{title}</h3>
          {body && <p className="mt-1.5 text-sm leading-relaxed text-text-secondary">{body}</p>}
          {consequences.length > 0 && (
            <ul className="mt-3 space-y-1.5">
              {consequences.map((c, i) => (
                <li key={i} className="flex gap-2 text-sm text-text-secondary">
                  <span className={cn("mt-1.5 h-1 w-1 shrink-0 rounded-full", destructive ? "bg-error" : "bg-text-tertiary")} />
                  <span>{c}</span>
                </li>
              ))}
            </ul>
          )}
          {mode === "type" && (
            <label className="mt-4 block text-sm text-text-secondary">
              Type <span className="font-semibold text-text">{typeToMatch}</span> to confirm.
              <input
                value={typed}
                onChange={(e) => setTyped(e.target.value)}
                autoFocus
                className={cn(controlBase, controlClasses(), "mt-1.5 h-10 px-2.5")}
              />
            </label>
          )}
        </div>
        <div className="flex items-center justify-end gap-2 border-t border-border bg-bg px-5 py-3">
          <Button variant="secondary" onClick={onCancel}>Cancel</Button>
          {mode === "hold" ? (
            <HoldToConfirm label={confirmLabel} onConfirm={onConfirm} onKeyboardFallback={onConfirm} />
          ) : (
            <Button variant={destructive ? "destructive" : "primary"} disabled={blocked} loading={loading} onClick={onConfirm}>
              {confirmLabel}
            </Button>
          )}
        </div>
      </div>
    </div>
  );
}

Dependencies

To use ConfirmDialog you also need the following. Copy these into your project alongside it.

Related components

Recommended folder structure

project structure
src/components/ui/
  ├─ button.tsx
  ├─ confirm-dialog.tsx
  ├─ field.tsx
  └─ hold-to-confirm.tsx
src/hooks/
  ├─ use-mounted.ts
  └─ use-popover-position.ts
src/lib/
  └─ utils.ts