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
Example
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
Example
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
ConfirmDialogdirectly withonConfirm.
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
Example
- Rent
- Groceries
- Insurance
'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. isPendingshows the same state for work you track yourself. The dialog stays open until you close it.pendingLabelnames 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 |
onOpenChange ((isOpen: boolean) => void) | Called when the dialog opens or closes, with |
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: Default: 'md' |
title ReactNode | The title, an h2 drawn in the card title step. It names the dialog. Required. |
tone "danger" | "default" |
|
useConfirm() takes no arguments and returns:
confirm (options: ConfirmOptions) => Promise<boolean> | Opens the dialog and resolves |
|---|---|
dialog ReactElement<unknown, string | JSXElementConstructor<any>> | null | The dialog element. Render it once in the component that calls |
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.
| Tab | Moves between Cancel and the confirm button. |
|---|---|
| Escape | Cancels. |
| EnterSpace | Press 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.