An alert dialog that asks one question, where only the confirm button means yes.

Basic

A Dialog that asks one question with two answers: Cancel, then a confirm button that names what it does. The title asks, and the required description says what will happen. Only the confirm button means yes. Escape, a press on the scrim and Cancel all mean no.

Use the default tone when the answer loses nothing, even if it cannot be taken back, such as sending an invoice. The confirm button is primary and takes focus on open.

Example

Follows the page theme and corners
import { Button, ConfirmDialog, DialogTrigger } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function ConfirmDialogBasic(): ReactElement {
  return (
    <DialogTrigger>
      <Button variant="secondary">Send the invoice</Button>
      <ConfirmDialog
        tone="default"
        title="Send the invoice now?"
        description="The client gets it by email at once, and it can no longer be edited."
        confirmLabel="Send invoice"
      />
    </DialogTrigger>
  );
}

Danger

Use tone="danger" when the answer loses something: data, money or work. It shows the warning triangle in the error well and the danger Button, and focus starts on Cancel, the safe answer. icon replaces the triangle. Name the outcome on the confirm button, such as Delete the workspace, never OK.

The tone is required. A destructive question shown as harmless is the costly mistake, and a harmless one shown as destructive teaches people to ignore the warning.

Example

Follows the page theme and corners
import { Button, ConfirmDialog, DialogTrigger } from '@snapshot-ui/react';
import type { ReactElement } from 'react';

export default function ConfirmDialogDanger(): ReactElement {
  return (
    <DialogTrigger>
      <Button variant="danger">Delete the workspace</Button>
      <ConfirmDialog
        tone="danger"
        title="Delete the workspace?"
        description="Its 12 projects and their history go with it. This cannot be undone."
        confirmLabel="Delete the workspace"
      />
    </DialogTrigger>
  );
}

Asking from code

useConfirm() asks from an event handler. confirm(options) opens the dialog and returns a promise that is true only when the confirm button is pressed. Render dialog once, in the component that asks. It is null until the first question.

  • Silence is never yes. A second question over an open one answers the first no. Unmounting answers an open question no, and so does a question asked after unmount.
  • Questions asked back to back, over an open one or chained after an answer, are one run. Each opens in the theme of the button that asked first and starts on its own answer. After the last answer, focus returns to that button.
  • The hook has no pending state. Show the work after a yes in your own interface, or render a ConfirmDialog directly with onConfirm.

When the action removes the button that asked, such as a row's Delete, focus has nowhere to return. Move it yourself after the action. This example moves it to the row that takes the removed row's place.

Example

  • Rent
  • Groceries
  • Insurance
Follows the page theme and corners
'use client';

import { Button, useConfirm } from '@snapshot-ui/react';
import { useEffect, useRef, useState, type ReactElement } from 'react';

const TRANSACTIONS = ['Rent', 'Groceries', 'Insurance'];

export default function ConfirmDialogHook(): ReactElement {
  const [rows, setRows] = useState(TRANSACTIONS);
  const { confirm, dialog } = useConfirm();
  // Each row's Delete button, so focus can be put on a row that stays.
  const buttons = useRef(new Map<string, HTMLButtonElement>());
  const restore = useRef<HTMLButtonElement>(null);
  // Where the removed row was, until the render without it has put focus somewhere.
  const removed = useRef<number | null>(null);

  // A removed row takes the button that asked with it, so focus goes to the row now in its place, or the
  // one before it, or the button that puts the list back.
  useEffect(() => {
    const index = removed.current;
    if (index === null) return;
    removed.current = null;
    const next = rows[index] ?? rows[index - 1];
    (next === undefined ? restore.current : buttons.current.get(next))?.focus();
  }, [rows]);

  const remove = async (name: string): Promise<void> => {
    const isSure = await confirm({
      tone: 'danger',
      title: `Delete ${name}?`,
      description: 'It leaves every report, with its notes. This cannot be undone.',
      confirmLabel: `Delete ${name}`,
    });
    if (!isSure) return;
    removed.current = rows.indexOf(name);
    setRows(rows.filter((row) => row !== name));
  };

  return (
    <div style={{ width: '100%', maxWidth: '24rem' }}>
      {rows.length > 0 ? (
        <ul
          aria-label="Transactions"
          style={{ display: 'grid', gap: 8, margin: 0, padding: 0, listStyle: 'none' }}
        >
          {rows.map((row) => (
            <li
              key={row}
              style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}
            >
              <span>{row}</span>
              <Button
                variant="secondary"
                aria-label={`Delete ${row}`}
                ref={(element) => {
                  if (element) buttons.current.set(row, element);
                  else buttons.current.delete(row);
                }}
                onPress={() => {
                  void remove(row);
                }}
              >
                Delete
              </Button>
            </li>
          ))}
        </ul>
      ) : (
        <Button
          ref={restore}
          variant="secondary"
          onPress={() => {
            setRows(TRANSACTIONS);
          }}
        >
          Put them back
        </Button>
      )}
      {dialog}
    </div>
  );
}

Usage

Open a confirm dialog from a DialogTrigger, or control it with isOpen and onOpenChange, as with Dialog. Inside a DialogTrigger, put onOpenChange on the trigger. On the dialog it is not called, and the library warns once.

onConfirm runs when the confirm button is pressed, and onCancel on every other way out. When onConfirm returns a promise, the dialog is pending until it settles:

  • The confirm button shows its spinner and keeps its label. Cancel is disabled, and Escape and the scrim do nothing.
  • On resolve, the dialog closes.
  • On reject, it stays open and leaves the pending state so the question can be answered again. The error goes to reportError, as if uncaught.
  • isPending shows the same state for work you track yourself. The dialog stays open until you close it.
  • pendingLabel names the spinner and is read with the confirm label. Use the outcome's verb, such as Deleting.
'use client';

import { Button, ConfirmDialog, DialogTrigger } from '@snapshot-ui/react';

export function DeleteWorkspace({ onDelete }: { onDelete: () => Promise<void> }) {
  return (
    <DialogTrigger>
      <Button variant="danger">Delete the workspace</Button>
      <ConfirmDialog
        tone="danger"
        title="Delete the workspace?"
        description="Its 12 projects and their history go with it. This cannot be undone."
        confirmLabel="Delete the workspace"
        onConfirm={onDelete}
      />
    </DialogTrigger>
  );
}

A press on the scrim cancels. For a question that must be answered, set isDismissable={false}. Escape still cancels.

Props

The rest of React Aria's dialog props and ref go on the dialog element, as on Dialog. A confirm dialog has no close button and no body: its answers are its two buttons.

cancelLabel
string

The cancel button's label.

Default: 'Cancel'

className
string | ((values: ModalRenderProps & { defaultClassName: string | undefined; }) => string)

Goes on the panel, merged after the library's classes.

confirmLabel
string

The confirm button's label. It names the outcome, "Delete 3 transactions", never "OK". Required.

defaultOpen
boolean

description
ReactNode

What the question does, under the title. Required: an alert dialog is described by it.

icon
ReactNode

The well's icon. In the danger tone it is Lucide's TriangleAlert unless given.

isDismissable
boolean

A press on the scrim closes the dialog.

Default: true

isKeyboardDismissDisabled
boolean

Escape no longer closes it.

Default: false

isOpen
boolean

Open state, for a dialog without a DialogTrigger. Controlled and uncontrolled.

isPending
boolean

Shows the pending state for work you track yourself. The confirm button shows its spinner, Cancel is disabled, and Escape and the scrim are ignored.

onCancel
(() => void)

Called on every other way out: Cancel, Escape, a scrim press.

onConfirm
(() => void | Promise<void>)

Called when the confirm button is pressed. A returned promise keeps the dialog pending until it settles. It closes on resolve. On reject it stays open and reports the error through reportError.

onOpenChange
((isOpen: boolean) => void)

Called when the dialog opens or closes, with isOpen or defaultOpen. Not called inside a DialogTrigger: put onOpenChange on the trigger.

overlayClassName
string

Goes on the scrim.

pendingLabel
string

The pending spinner's accessible name, read with the confirm label. Use the outcome's verb, such as "Deleting".

Default: 'Loading'

size
"lg" | "md"

The panel width: md is 28rem, lg is 40rem, both capped at the viewport less 2rem.

Default: 'md'

title
ReactNode

The title, an h2 drawn in the card title step. It names the dialog. Required.

tone
"danger" | "default"

danger shows the error icon well and the danger Button, for a question that loses something. default shows a primary Button, and no well unless icon is given. Required, with no default.

useConfirm() takes no arguments and returns:

confirm
(options: ConfirmOptions) => Promise<boolean>

Opens the dialog and resolves true only when the confirm button is pressed.

dialog
ReactElement<unknown, string | JSXElementConstructor<any>> | null

The dialog element. Render it once in the component that calls confirm. null until the first call.

Accessibility

An alertdialog, named by its title and described by its description, so both are read on open. Focus starts on Cancel in the danger tone and on the confirm button in the default tone. Tab moves between the two buttons, and on close focus returns to the button that opened it. While pending, the confirm button's name is its label then the spinner's, such as Delete 3 transactions Deleting, and React Aria announces it.

TabMoves between Cancel and the confirm button.
EscapeCancels.
EnterSpacePress the focused button.

Design rules

What keeps confirm dialogs honest.

Name the outcome on the confirm button, such as Delete 3 transactions.

Use the danger tone when the answer loses something: data, money or work.

Use the default tone when it loses nothing, even if it cannot be taken back, such as sending.

Keep the description to what will happen.

Never label the confirm button OK or Yes.

Never ask before an action that Undo can take back. Offer Undo after it instead.

Never pick a tone by default. Choose the one the question deserves.

esc

↑ ↓ to move↵ to open