Skip to content

Confirm dialog

The standard confirmation for destructive actions, with an optional typed-name check.

Status
Stable
Category
Overlays
Adoption
Not used yet
import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";
packages/canon/src/components/confirm-dialog.tsx
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { toast } from "@oration/canon/components/toast";import { Trash2Icon } from "lucide-react";import * as React from "react";export function Hero() {    const [open, setOpen] = React.useState(false);    return (        <>            <Button                type="button"                variant="destructive"                onClick={() => setOpen(true)}            >                <Trash2Icon data-icon="inline-start" aria-hidden="true" />                Delete supplier            </Button>            <ConfirmDialog                open={open}                onOpenChange={setOpen}                title="Delete Northwind Freight?"                description="Its 38 paid invoices stay in your payment history. The 2 open invoices are removed from Friday's run."                confirmLabel="Delete supplier"                onConfirm={() =>                    toast.add({ title: "Northwind Freight deleted" })                }            />        </>    );}

Usage#

Confirm dialog is the one confirmation the suite uses before a consequential action: delete a supplier, remove an API key, rotate a secret, turn off the last live key. You pass the question, the consequence and a confirm label, and it handles the rest: Cancel takes first focus, destructive actions get the red tint, a returned promise shows a spinner and holds the dialog open, and confirmText adds a typed-name check for losses that can't be undone. The common mistake is a vague confirm label. The button repeats the consequence (Delete supplier, Remove key), never OK or Yes.

When to use

  • Before a delete or remove that can't be undone: Delete Northwind Freight?, Remove key?
  • Before an action with a real consequence that isn't a delete, such as rotating a key or turning off autopay, with destructive={false}.
  • With confirmText when the loss is large and permanent, such as deleting a payment run with its invoices, so the name has to be typed.
  • When confirming calls the server: return the promise from onConfirm and the dialog waits with a spinner.
  • From a table or list row, with usePendingItem holding the row while the dialog animates closed.

When not to use

  • For an action that can be undone. Do it, then offer Undo in a toast. Use Toast
  • For a decision with three answers, or a layout the props can't express. Use Alert dialog
  • For a single high-stakes button in place, where a dialog would break the flow. Use Hold button
  • For a form with fields and a Save. Use Form dialog

Destructive is a tint

The confirm button of a destructive confirmation is the 10% Signal Red tint with red text, never a solid red fill. The words carry the weight.

The One Filled Button Rule

A non-destructive confirmation has one filled indigo button, the confirm. A destructive one has none: Cancel is outline and the confirm is the red tint.

Anatomy#

Remove Halcyon?

Scheduled payments to Halcyon are cancelled.

4 invoices, $22,418.50
Type Halcyon to confirm
Halcyon
  1. Title. The question, naming the object: Delete Northwind Freight? Rendered by AlertDialogTitle.
  2. Description. What happens and to what, in one or two sentences of Slate Meta.
  3. Body. Optional children between the header and the footer, such as a grace-period select when rotating a key.
  4. Typed check. With confirmText: a 13px Slate Meta label with the name in inline code, and an input with autocomplete, capitalization and spellcheck off.
  5. Cancel. An outline AlertDialogCancel, first in the DOM and first to take focus. Labelled by cancelLabel.
  6. Confirm. The red tint by default, the filled indigo button with destructive={false}. Shows a 14px spinner before its label while pending.

Examples#

Not destructive, with a body

destructive={false} makes the confirm the filled indigo button. children render between the header and the footer, here the grace period for the old key.

import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { Field, FieldLabel } from "@oration/canon/components/field";import { OptionSelect } from "@oration/canon/components/option-select";import { toast } from "@oration/canon/components/toast";import { RefreshCwIcon } from "lucide-react";import * as React from "react";export function NotDestructive() {    const [open, setOpen] = React.useState(false);    const [grace, setGrace] = React.useState("24 hours");    const id = React.useId();    return (        <>            <Button                type="button"                variant="outline"                onClick={() => setOpen(true)}            >                <RefreshCwIcon data-icon="inline-start" aria-hidden="true" />                Rotate key            </Button>            <ConfirmDialog                open={open}                onOpenChange={setOpen}                title="Rotate the production API key?"                description="You get a new key right away. Update the ERP sync before the old key stops working."                confirmLabel="Rotate key"                destructive={false}                onConfirm={() =>                    toast.add({                        type: "success",                        title: "Key rotated",                        description:                            grace === "Now"                                ? "The old key stopped working."                                : `The old key works for ${grace}.`,                    })                }            >                <Field>                    <FieldLabel htmlFor={id}>                        Keep the old key working for                    </FieldLabel>                    <OptionSelect                        id={id}                        label="Keep the old key working for"                        value={grace}                        onValueChange={setGrace}                        options={["Now", "24 hours", "7 days"]}                    />                </Field>            </ConfirmDialog>        </>    );}

Typed confirmation

confirmText adds a field that must match exactly before the confirm enables. Focus starts in the field, Enter confirms and the surface widens to 28rem. Type PR-0412.

import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { toast } from "@oration/canon/components/toast";import { Trash2Icon } from "lucide-react";import * as React from "react";export function TypedConfirmation() {    const [open, setOpen] = React.useState(false);    return (        <>            <Button                type="button"                variant="destructive"                onClick={() => setOpen(true)}            >                <Trash2Icon data-icon="inline-start" aria-hidden="true" />                Delete payment run            </Button>            <ConfirmDialog                open={open}                onOpenChange={setOpen}                title="Delete payment run PR-0412?"                description="Its 212 invoices go back to Approved and the bank file for Friday, Oct 2 is withdrawn. This can't be undone."                confirmLabel="Delete run"                confirmText="PR-0412"                onConfirm={() =>                    toast.add({                        title: "PR-0412 deleted",                        description: "212 invoices are back in Approved.",                    })                }            />        </>    );}

Waiting on the server

Return a promise from onConfirm. The confirm shows a spinner, both buttons disable and Esc is ignored until it resolves, then the dialog closes.

import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Pending() {    const [open, setOpen] = React.useState(false);    return (        <>            <Button                type="button"                variant="outline"                onClick={() => setOpen(true)}            >                Turn off autopay            </Button>            <ConfirmDialog                open={open}                onOpenChange={setOpen}                title="Turn off autopay for Orchard Street?"                description="New invoices from Orchard Street wait for approval. The 3 already scheduled still go out Friday."                confirmLabel="Turn off autopay"                onConfirm={() =>                    new Promise<void>((resolve) => {                        window.setTimeout(() => {                            toast.add({                                title: "Autopay turned off for Orchard Street",                            });                            resolve();                        }, 1400);                    })                }            />        </>    );}

From a row action with usePendingItem

show(row) opens the dialog for that row. item outlives the close, so the title still names the account while the dialog animates out after the row is gone.

  • Northwind FreightChase ending 4417
  • HalcyonWells Fargo ending 0932
  • Orchard StreetSVB ending 7781
import { Button } from "@oration/canon/components/button";import { ConfirmDialog, usePendingItem } from "@oration/canon/components/confirm-dialog";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuItem,  DropdownMenuSeparator,  DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { MoreHorizontalIcon, Trash2Icon } from "lucide-react";import * as React from "react";export function FromRows() {    const [accounts, setAccounts] = React.useState([        {            id: "a1",            supplier: "Northwind Freight",            bank: "Chase",            last4: "4417",        },        { id: "a2", supplier: "Halcyon", bank: "Wells Fargo", last4: "0932" },        { id: "a3", supplier: "Orchard Street", bank: "SVB", last4: "7781" },    ]);    const remove = usePendingItem<(typeof accounts)[number]>();    return (        <div className="w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border">            <ul className="divide-y divide-border">                {accounts.map((account) => (                    <li                        key={account.id}                        className="flex items-center gap-3 py-2 pr-2 pl-4 text-13"                    >                        <span className="min-w-0 flex-1">                            <span className="block font-medium">                                {account.supplier}                            </span>                            <span className="block text-muted-foreground tabular-nums">                                {account.bank} ending {account.last4}                            </span>                        </span>                        <DropdownMenu>                            <Tooltip>                                <TooltipTrigger                                    render={                                        <DropdownMenuTrigger                                            render={                                                <Button                                                    type="button"                                                    variant="ghost"                                                    size="icon-sm"                                                    aria-label={`Actions for ${account.supplier}`}                                                />                                            }                                        />                                    }                                >                                    <MoreHorizontalIcon aria-hidden="true" />                                </TooltipTrigger>                                <TooltipContent>Actions</TooltipContent>                            </Tooltip>                            <DropdownMenuContent align="end" className="w-48">                                <DropdownMenuItem                                    onClick={() =>                                        toast.add({                                            title: `Verification sent to ${account.supplier}`,                                        })                                    }                                >                                    Re-verify account                                </DropdownMenuItem>                                <DropdownMenuSeparator />                                <DropdownMenuItem                                    variant="destructive"                                    onClick={() => remove.show(account)}                                >                                    <Trash2Icon aria-hidden="true" />                                    Remove account                                </DropdownMenuItem>                            </DropdownMenuContent>                        </DropdownMenu>                    </li>                ))}                {accounts.length === 0 ? (                    <li className="px-4 py-6 text-center text-13 text-muted-foreground">                        No bank accounts on file.                    </li>                ) : null}            </ul>            <ConfirmDialog                open={remove.open}                onOpenChange={remove.setOpen}                title={`Remove ${remove.item?.supplier ?? "this supplier"}'s bank account?`}                description={`Payments to ${remove.item?.supplier ?? "this supplier"} pause until a new account is verified.`}                confirmLabel="Remove account"                onConfirm={() => {                    const target = remove.item;                    if (!target) return;                    setAccounts((all) => all.filter((a) => a.id !== target.id));                    toast.add({                        title: `Removed ${target.bank} ending ${target.last4}`,                    });                }}            />        </div>    );}

States#

Waiting for the name
Ready
Pending
import { Button } from "@oration/canon/components/button";import { Spinner } from "@oration/canon/components/spinner";export function StatesRow() {    const states = [        {            label: "Waiting for the name",            confirm: (                <Button type="button" variant="destructive" size="sm" disabled>                    Delete run                </Button>            ),        },        {            label: "Ready",            confirm: (                <Button type="button" variant="destructive" size="sm">                    Delete run                </Button>            ),        },        {            label: "Pending",            confirm: (                <Button type="button" variant="destructive" size="sm" disabled>                    <span aria-hidden="true" className="flex">                        <Spinner className="size-3.5" />                    </span>                    Delete run                </Button>            ),        },    ];    return (        <div className="grid w-full gap-3 sm:grid-cols-3" inert>            {states.map((state) => (                <div key={state.label} className="flex flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {state.label}                    </span>                    <div className="flex justify-end gap-2 rounded-[10px] bg-muted/70 p-3">                        <Button                            type="button"                            variant="outline"                            size="sm"                            disabled={state.label === "Pending"}                        >                            Cancel                        </Button>                        {state.confirm}                    </div>                </div>            ))}        </div>    );}
States
StateTreatment
ClosedNothing is rendered. With usePendingItem, the last item is kept so the title doesn't change while the dialog animates out.
OpenFocus is on Cancel, or on the typed input when confirmText is set. The typed value and pending state reset every time it opens.
Waiting for the nameWith confirmText, the confirm button is disabled until the trimmed input matches exactly, including case.
DisabledconfirmDisabled disables the confirm button for any other reason, such as a body field that isn't filled in.
PendingWhen onConfirm returns a promise: a spinner before the label, both buttons disabled, and Esc ignored until it settles.
ClosingCloses when onConfirm returns, or when its promise resolves. A rejected promise leaves it open.

Behavior#

  • Controlled only: open and onOpenChange are required. The trigger lives outside, usually a menu item, a row action or a destructive button in a settings section.
  • Built on Alert dialog, so the scrim doesn't close it. Esc and Cancel do, except while pending.
  • Confirming calls onConfirm. A plain return closes the dialog right away. A returned promise keeps it open with the spinner, then closes on resolve. On reject it stays open and the error propagates, so catch it and show a toast yourself.
  • With confirmText, the input sits in a form, so Enter confirms once the name matches. The comparison trims whitespace and is case-sensitive. The surface widens to 28rem.
  • usePendingItem<T>() returns { item, open, show, setOpen, close }. Call show(row) from the trigger, read item in the title, and pass open and setOpen to the dialog. item survives the close so the title never flashes to a fallback.
  • Timings are Alert dialog's: 200ms in from 0.96, 140ms out toward 0.98.

Choosing an overlay#

Seven components take over the screen. Pick by the question the overlay asks: a yes or no goes to a confirmation, a few fields to a form dialog, details that belong beside a list to a sheet. For a few controls tied to one button, use a popover instead.

Which overlay to use
ComponentReach for it whenShapeDismissed by
DialogA focused task the page waits for: a short form, a review, a setting with its own Save. You compose the header, body and footer.Centered, 24rem by default, up to 48remEsc, a click on the scrim, the close button
Confirm dialog (this page)Confirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large.Centered, 24rem, 28rem when typedEsc or an answer. The scrim doesn't close it
Alert dialogA decision Confirm dialog can't express, such as three answers or a media tile. You build the parts; Confirm dialog is built on it.Centered, 20 or 24remEsc or an answer. The scrim doesn't close it
Form dialogCreating or editing one record with two to six fields, a submit and a cancel.Centered, 28remEsc, the scrim, Cancel, the close button
Stacked dialogThree to six pages read or completed in order: what's new, connecting a provider.Centered, 28rem, with a control barEsc, the scrim, Done, the close button
SheetRecord details, logs or a longer edit that should keep the list it came from in view.From an edge, 24rem wide by defaultEsc, the scrim, the close button
DrawerTouch-first flows on phones that people expect to swipe away. Not used in the product yet.From the bottom, up to the viewport minus 6remA swipe, Esc, the scrim

Do and don't#

Delete Northwind Freight?

The 2 open invoices are removed from Friday's run.

Do. Repeat the consequence on the button: a dialog titled Delete Northwind Freight? confirms with Delete supplier.

Are you sure?

This action cannot be undone.

Don't. Answer with OK or Yes. People confirm without reading, and the button says nothing about what goes away.

Delete payment run PR-0412?

212 invoices go back to Approved and the bank file is withdrawn.

Type PR-0412 to confirm
Do. Save the typed check for permanent, large losses: a payment run with its invoices, a production key.

Skip INV-20944?

It moves to next Friday's run.

Type INV-20944 to confirm
Don't. Ask for a typed name to skip one invoice. When every confirmation is typed, people learn to type without thinking.
Do. Say what happens next in the description, with the numbers: Widgets on cedarline.io stop loading.
Don't. Write This action cannot be undone. and nothing else. It warns without saying what's at stake.

Content#

  • Title: a question with the object's name and a question mark. Remove Halcyon's bank account?, not Are you sure?
  • Description: the consequence in one or two sentences. Name what stops working and what's kept. Offer the gentler alternative if there is one: To pause it instead, turn it off.
  • Confirm label: the verb and object from the title. Delete supplier, Remove key, Rotate key.
  • Cancel label: keep Cancel unless backing out has a clearer name, such as Keep key.
  • The typed label defaults to Type {name} to confirm. Replace it with confirmTextLabel only to say where the name comes from.
  • After it succeeds, confirm with a toast that names the object: Northwind Freight deleted.

Accessibility#

  • The surface is role="alertdialog", named by the title and described by the description, so the question and its consequence are announced together.
  • Initial focus lands on Cancel, so a stray Enter backs out. With confirmText, it lands on the input instead.
  • The typed input is labelled by its visible label through htmlFor.
  • While pending, the confirm label stays in place and the spinner is hidden from assistive tech. Nothing announces the wait; follow up with a toast when it settles.
  • Focus returns to the element that opened it. When the trigger was a row that's now deleted, move focus somewhere sensible, such as the table.
Keyboard interactions
KeysAction
TabMoves between the input, Cancel and the confirm button, wrapping.
EnterActivates the focused button. In the typed input, confirms once the name matches.
EscCancels, unless a confirmation is pending.

Design tokens#

Design tokens
TokenUsed for
--destructiveConfirm text and its 10% tint when destructive
--primaryConfirm fill when destructive={false}
--mutedFooter band at 50%
--muted-foregroundDescription and the typed-check label
--popoverSurface fill
shadow-lgThe dialog shadow

API reference#

ConfirmDialog

A controlled confirmation built on Alert dialog. Takes no other props.

Props of ConfirmDialog
PropTypeDefaultDescription
openRequiredbooleanNo defaultWhether it is open.
onOpenChangeRequired(open: boolean) => voidNo defaultCalled on Cancel, Esc and after a confirm. Ignored while pending.
titleRequiredReact.ReactNodeNo defaultThe question, naming the object.
descriptionRequiredReact.ReactNodeNo defaultWhat happens and to what.
confirmLabelRequiredReact.ReactNodeNo defaultThe confirm button's label. Repeat the consequence.
cancelLabelReact.ReactNode"Cancel"The cancel button's label.
onConfirmRequired() => unknownNo defaultRuns on confirm. Return a promise to keep the dialog open with a spinner until it settles.
destructivebooleantrueThe red tint. Set false for a consequential action that doesn't destroy anything.
confirmDisabledbooleanNo defaultDisables the confirm button.
confirmTextstringNo defaultThe exact text to type before confirming. Widens the surface to 28rem.
confirmTextLabelReact.ReactNodeNo defaultReplaces the Type {confirmText} to confirm label.
childrenReact.ReactNodeNo defaultBody content between the header and the footer.
classNamestringNo defaultMerged onto AlertDialogContent.

usePendingItem

usePendingItem<T>() holds the target of a confirmation. Returns { item: T | null, open: boolean, show: (item: T) => void, setOpen: (open: boolean) => void, close: () => void }.

No props of its own.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

A rejected onConfirm promise isn't caught. The dialog stays open with no message and the rejection surfaces as an unhandled error, so every async caller has to catch and toast.

Pending isn't announced: there is no aria-busy and the spinner is hidden. Screen reader users hear nothing until the dialog closes.

The typed check is case-sensitive with no hint. Typing halcyon for Halcyon keeps the button disabled without saying why.

Some callers, such as confirm-delete-dialog.tsx, call onOpenChange(false) inside onConfirm, which the component already does.