ComponentsOverlays

Confirm dialog

Asks once before an action that can't be undone or costs something. It says what will happen, and the button repeats the verb.

Playground

Press the button, then Cancel to come back to the controls. The code under the preview lists only the props you changed.

import { ConfirmDialog } from '@/ui/confirm-dialog';<ConfirmDialog title="Delete Orbital Freight?" description="The company and its notes go for good." action="Delete company" />

Destructive

The danger action names what goes. Focus starts on Cancel, so Enter alone never deletes. There is no close button: Cancel and Escape are the way out.

import { useState } from 'react';
import { Button } from '@/ui/button';
import { ConfirmDialog } from '@/ui/confirm-dialog';
import { toast } from '@/ui/toast';

// Level 2: permanent, one record. Soft red action; focus starts on Cancel, so Enter alone never deletes.
export default function ConfirmDialogDefault() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button variant="danger" onClick={() => setOpen(true)}>
        Delete view
      </Button>
      <ConfirmDialog
        open={open}
        onOpenChange={setOpen}
        title="Delete “Shortlist this week”?"
        description="Everyone at Northbridge Talent loses this view. The candidates in it stay."
        action="Delete view"
        onAction={() => {
          setOpen(false);
          toast.add({ title: 'View deleted' });
        }}
      />
    </>
  );
}

Destructive levels

The ask scales with the damage. Pick the level by what can be undone and how far it reaches, never by how serious the button feels.

  1. Can be undone: no dialog. It happens, and a toast says what happened with Undo.
  2. Permanent, one record: this dialog with a soft red action.
  3. Permanent and wide (bulk delete, delete with related records, remove a member): type a phrase such as "delete 24"; the solid red action enables only when it matches. No hold-to-delete.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { ConfirmDialog } from '@/ui/confirm-dialog';
import { toast } from '@/ui/toast';

// Decided 8: the ask scales with the damage. Level 1 acts at once with Undo; level 2 confirms with a soft red action;
// level 3 asks for a typed phrase before the solid red action enables.
export default function ConfirmDialogLevels() {
  const [open, setOpen] = useState<2 | 3 | null>(null);
  return (
    <div className="flex flex-wrap gap-100">
      <Button onClick={() => toast.add({ title: 'Maya Chen archived', actionProps: { children: 'Undo' } })}>Archive Maya Chen</Button>
      <Button variant="danger" onClick={() => setOpen(2)}>
        Delete Orbital Freight
      </Button>
      <Button variant="danger-bold" onClick={() => setOpen(3)}>
        Delete 24 candidates
      </Button>
      <ConfirmDialog
        open={open === 2}
        onOpenChange={(next) => !next && setOpen(null)}
        title="Delete Orbital Freight?"
        description="The company and its notes go for good. Its contacts and jobs stay, without a company."
        action="Delete company"
        onAction={() => setOpen(null)}
      />
      <ConfirmDialog
        open={open === 3}
        onOpenChange={(next) => !next && setOpen(null)}
        title="Delete 24 candidates?"
        description="Their applications, notes and files go too. This can’t be undone."
        action="Delete 24 candidates"
        confirmPhrase="delete 24"
        onAction={() => setOpen(null)}
      />
    </div>
  );
}

Costly, and with a choice

A costly but expected action takes the primary button, and focus starts on it; the description counts what happens, with names. With a picker, the action stays disabled until the choice is made.

import { useState } from 'react';
import { Button } from '@/ui/button';
import { ConfirmDialog } from '@/ui/confirm-dialog';
import { Field, FieldLabel } from '@/ui/field';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/ui/select';

const reasons = { salary: 'Salary too high', notice: 'Notice period too long', fit: 'Not the right fit' };

// Costly but expected: the primary action, focus on it. With a picker, the action waits until a choice is made.
export default function ConfirmDialogCostly() {
  const [open, setOpen] = useState<'send' | 'close' | null>(null);
  const [reason, setReason] = useState<string | null>(null);
  return (
    <div className="flex flex-wrap gap-100">
      <Button variant="primary" onClick={() => setOpen('send')}>
        Send 3 CVs
      </Button>
      <Button onClick={() => setOpen('close')}>Close application</Button>
      <ConfirmDialog
        open={open === 'send'}
        onOpenChange={(next) => !next && setOpen(null)}
        appearance="primary"
        title="Send 3 CVs to Halcyon Bank?"
        description="Sara Lund gets one email with Maya Chen, Daniel Okafor and Priya Shah. Their stage moves to CV sent."
        action="Send CVs"
        onAction={() => setOpen(null)}
      />
      <ConfirmDialog
        open={open === 'close'}
        onOpenChange={(next) => !next && setOpen(null)}
        appearance="primary"
        title="Close Maya Chen’s application?"
        description="It leaves the board for Senior Data Engineer. You can reopen it later."
        action="Close application"
        disabled={!reason}
        onAction={() => setOpen(null)}
      >
        <Field>
          <FieldLabel id="close-reason-label">Reason</FieldLabel>
          <Select items={reasons} value={reason} onValueChange={setReason}>
            <SelectTrigger aria-labelledby="close-reason-label">
              <SelectValue placeholder="Choose a reason" />
            </SelectTrigger>
            <SelectContent>
              {Object.entries(reasons).map(([value, label]) => (
                <SelectItem key={value} value={value}>
                  {label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </Field>
      </ConfirmDialog>
    </div>
  );
}

Working and a failed action

While the action runs, its button shows a spinner and Cancel and Escape wait; a second press does nothing. When it fails, the dialog stays open and says what happened and what to do next. This one fails the first time. On success the dialog closes and a toast says what happened.

import { useState } from 'react';
import { Button } from '@/ui/button';
import { ConfirmDialog } from '@/ui/confirm-dialog';

// Working: a spinner in the action, Cancel and Escape wait, a second press does nothing. The first try fails: the dialog
// stays open and says what to do. The second works.
export default function ConfirmDialogUnhappy() {
  const [open, setOpen] = useState(false);
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [tries, setTries] = useState(0);
  return (
    <>
      <Button variant="danger" onClick={() => setOpen(true)}>
        Delete “Nguyễn Thị Minh Khai – Senior Data Engineer shortlist”
      </Button>
      <ConfirmDialog
        open={open}
        onOpenChange={(next) => {
          setOpen(next);
          if (!next) setError(null);
        }}
        title="Delete “Nguyễn Thị Minh Khai – Senior Data Engineer shortlist”?"
        description="Everyone at Northbridge Talent loses this view. The candidates in it stay."
        action="Delete view"
        pending={pending}
        error={error}
        onAction={() => {
          setPending(true);
          setError(null);
          setTimeout(() => {
            setPending(false);
            setTries(tries + 1);
            if (tries === 0) setError('Couldn’t delete the view. Check your connection and try again.');
            else setOpen(false);
          }, 1200);
        }}
      />
    </>
  );
}

Motion

The confirm dialog uses the Dialog motion. Press Replay to open it after a beat, then Cancel or Escape to see it close.

import { useState } from 'react';
import { Button } from '@/ui/button';
import { ConfirmDialog } from '@/ui/confirm-dialog';

// Replay opens the dialog after a beat, so the 200ms entry plays; Cancel or Escape plays the 150ms exit.
export default function ConfirmDialogMotion() {
  const [open, setOpen] = useState(false);
  const replay = () => {
    setOpen(false);
    setTimeout(() => setOpen(true), 400);
  };
  return (
    <>
      <Button variant="ghost" onClick={replay}>
        Replay
      </Button>
      <ConfirmDialog
        open={open}
        onOpenChange={setOpen}
        title="Delete Orbital Freight?"
        description="The company and its notes go for good."
        action="Delete company"
        onAction={() => setOpen(false)}
      />
    </>
  );
}

On this page