ConfirmDialog

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

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

Specification

Modal confirmation for consequential actions, supporting simple, type-to-confirm and press-and-hold modes.

Props (11)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
open*boolean—Controls whether the dialog is rendered.
tone"default" | "destructive""default"Sets the visual emphasis; destructive uses error styling for dangerous actions.
title*string—Heading question shown at the top of the dialog.
bodystring—Optional supporting text explaining the action.
consequencesstring[][]Bulleted list of side effects shown to the user.
confirmLabel*string—Label for the confirm button.
mode"simple" | "type" | "hold""simple"Confirmation strategy: a plain button, type-to-match, or press-and-hold.
typeToMatchstring—The exact string the user must type when mode is "type".
onConfirm*() => void—Called when the user confirms the action.
onCancel*() => void—Called when the user cancels or dismisses the dialog.
loadingboolean—Shows a pending state on the confirm button while the action runs.

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

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/components/ui/field.tsx
field.tsx
"use client";

import { type ReactNode } from "react";
import { cn } from "@/lib/utils";

interface FieldCounter {
  text: string;
  warn?: boolean;
}

interface FieldProps {
  label?: string;
  hint?: string;
  error?: string;
  required?: boolean;
  htmlFor?: string;
  counter?: FieldCounter;

  messageId?: string;
  children: ReactNode;
}

export function Field({
  label,
  hint,
  error,
  required,
  htmlFor,
  counter,
  messageId,
  children,
}: FieldProps) {
  const message = error || hint;
  return (
    <div className="flex w-full flex-col gap-1.5">
      {label && (
        <label
          htmlFor={htmlFor}
          className="flex items-center gap-1 text-[13px] font-medium text-text"
        >
          {label}
          {required && (
            <span className="text-error" aria-hidden>
              *
            </span>
          )}
        </label>
      )}
      {children}
      <div className="flex items-start justify-between gap-3">
        <p
          id={messageId}
          className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
        >
          {message || "\u00A0"}
        </p>
        {counter && (
          <span
            className={cn(
              "shrink-0 text-xs tabular-nums",
              counter.warn ? "text-error" : "text-text-tertiary"
            )}
          >
            {counter.text}
          </span>
        )}
      </div>
    </div>
  );
}

export const controlBase = cn(
  "w-full rounded-lg border text-sm bg-surface text-text transition-colors",
  "placeholder:text-text-tertiary",
  "focus:outline-none focus-visible:outline-none focus-visible:border-accent",
  "disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
  "read-only:bg-bg-secondary"
);

export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
  return cn(
    error ? "border-error focus-visible:border-error" : "border-border",
    readOnly && "read-only:bg-bg-secondary",
    disabled && "text-text-tertiary"
  );
}
src/hooks/use-mounted.ts
use-mounted.ts
"use client";

import { useState, useEffect } from "react";

/**
 * Returns true once the component has mounted on the client.
 * Useful for portals and anything that needs `document` to exist.
 */
export function useMounted(): boolean {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted;
}
src/hooks/use-popover-position.ts
use-popover-position.ts
"use client";

import { useState, useEffect, useCallback, type RefObject, type CSSProperties } from "react";

interface PopoverPositionOptions {
  align?: "start" | "center" | "end";
  gap?: number;
  viewportPadding?: number;
  preferredHeight?: number;
  matchWidth?: boolean;
  minWidth?: number;
}

interface PopoverPosition {
  placement: "top" | "bottom";
  style: CSSProperties;
}

/**
 * Compute a fixed-position popover style anchored to a ref element.
 * Opens downward by default; flips upward when there isn't room below
 * AND there's more room above. Clamps horizontally so nothing renders
 * past the viewport edge. Re-measures on scroll/resize while open.
 */
export function usePopoverPosition(
  anchorRef: RefObject<HTMLElement | null>,
  open: boolean,
  options: PopoverPositionOptions = {}
): PopoverPosition | null {
  const {
    align = "start",
    gap = 4,
    viewportPadding = 8,
    preferredHeight = 320,
    matchWidth = false,
    minWidth,
  } = options;

  const [position, setPosition] = useState<PopoverPosition | null>(null);

  const measure = useCallback(() => {
    const anchor = anchorRef.current;
    if (!anchor) return;
    const rect = anchor.getBoundingClientRect();
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    const spaceBelow = vh - rect.bottom - viewportPadding;
    const spaceAbove = rect.top - viewportPadding;
    const placement =
      spaceBelow < Math.min(preferredHeight, 160) && spaceAbove > spaceBelow
        ? "top"
        : "bottom";
    const maxHeight = Math.max(
      120,
      Math.min(preferredHeight, placement === "top" ? spaceAbove : spaceBelow)
    );
    const width = matchWidth ? rect.width : minWidth;
    let left = align === "end" && width ? rect.right - width : rect.left;
    if (align === "center" && width)
      left = rect.left + rect.width / 2 - width / 2;
    left = width
      ? Math.min(Math.max(left, viewportPadding), vw - width - viewportPadding)
      : Math.min(Math.max(left, viewportPadding), vw - viewportPadding);

    setPosition({
      placement,
      style: {
        position: "fixed",
        top: placement === "top" ? undefined : rect.bottom + gap,
        bottom: placement === "top" ? vh - rect.top + gap : undefined,
        left,
        width: matchWidth ? rect.width : undefined,
        minWidth: !matchWidth ? minWidth : undefined,
        maxHeight,
      },
    });
  }, [anchorRef, align, gap, viewportPadding, preferredHeight, matchWidth, minWidth]);

  useEffect(() => {
    if (!open) {
      setPosition(null);
      return;
    }
    measure();
    window.addEventListener("scroll", measure, true);
    window.addEventListener("resize", measure);
    return () => {
      window.removeEventListener("scroll", measure, true);
      window.removeEventListener("resize", measure);
    };
  }, [open, measure]);

  return position;
}
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}