Skip to content

Destructive actions

Undo first, confirm when undo is impossible, and type to confirm when the loss is large.

The problem#

Some actions lose things: invoices, payments, whole suppliers. Protect people from the irreversible ones without taxing the reversible ones.

The usual answer is a confirmation dialog on everything that removes something. It fails twice. People learn to click through Are you sure? without reading, so the dialog stops protecting the one action that needed it. And every reversible archive or unassign now costs an extra click and a context switch.

In accounts payable the stakes are uneven. Archiving a draft invoice costs nothing to reverse. Voiding a payment that already left for the bank, or deleting a supplier with three years of remittance history, can't be taken back.

The solution in Canon#

Match the friction to the loss. Make it reversible and offer Undo when you can; confirm when you can't; ask for the name when the loss is large.

Undo beats confirm

If the change can be reversed, do it at once and offer Undo in the toast. Don't ask first.

Name the thing, repeat the verb

The title asks about the specific record. The confirm button repeats the verb and the noun: Delete supplier, never OK.

Destructive is a red tint

A 10% red tint with red text, never a solid red fill. It is never the view's filled indigo button.

Decision guide#

Pick the lightest protection that matches the loss.

Which protection
The lossReversiblePatternExample
Archive, remove from a view, unassign, move to trashYesDo it, toast with UndoArchive INV-20451
The same, in bulkYesDo it, toast with the count and UndoArchive 12 drafts
Irreversible, one record, containedNoConfirm dialogVoid payment PAY-1182
Irreversible and large: history, many records, other peopleNoType the name to confirmDelete supplier Halcyon Packaging
Irreversible, in a live panel a dialog would coverNoHold buttonCancel RUN-0932 while it sends
Workspace or accountNoType to confirm, from a section at the end of settingsDelete the Cedarline workspace

Prefer undo#

Archive a draft and it goes at once. The toast names it and offers Undo for five seconds; the record is kept for 30 days either way.

Draft invoices

4 drafts
  • Northwind Freight

    INV-20451

    $2,140.00
  • Orchard Street Foods

    INV-20453

    $612.40
  • Halcyon Packaging

    INV-20455

    $4,912.50
  • Pinecrest Supply

    INV-20456

    $1,264.00
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { ArchiveIcon, RotateCcwIcon } from "lucide-react";import * as React from "react";export function UndoFirst() {    type Draft = { id: string; supplier: string; amount: string };    const all: Draft[] = [        { id: "INV-20451", supplier: "Northwind Freight", amount: "$2,140.00" },        {            id: "INV-20453",            supplier: "Orchard Street Foods",            amount: "$612.40",        },        { id: "INV-20455", supplier: "Halcyon Packaging", amount: "$4,912.50" },        { id: "INV-20456", supplier: "Pinecrest Supply", amount: "$1,264.00" },    ];    const [rows, setRows] = React.useState<Draft[]>(all);    const archive = (row: Draft) => {        const index = rows.findIndex((item) => item.id === row.id);        setRows((list) => list.filter((item) => item.id !== row.id));        toast.add({            title: `${row.id} archived`,            description: `${row.supplier}, ${row.amount}. Archived drafts are kept for 30 days.`,            actionProps: {                children: "Undo",                onClick: () =>                    setRows((list) => {                        if (list.some((item) => item.id === row.id))                            return list;                        const next = [...list];                        next.splice(Math.min(index, next.length), 0, row);                        return next;                    }),            },        });    };    return (        <section className="w-full max-w-lg overflow-hidden rounded-xl bg-card text-left shadow-border">            <div className="flex items-baseline justify-between gap-3 border-b border-border px-4 py-3">                <h3 className="text-sm font-semibold">Draft invoices</h3>                <span className="text-xs text-muted-foreground tabular-nums">                    {rows.length} drafts                </span>            </div>            {rows.length ? (                <ul className="px-2 py-1">                    {rows.map((row) => (                        <li                            key={row.id}                            className="group/row flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-muted/70"                        >                            <div className="min-w-0 flex-1">                                <p className="truncate text-13 font-medium">                                    {row.supplier}                                </p>                                <p className="font-mono text-xs text-muted-foreground">                                    {row.id}                                </p>                            </div>                            <span className="text-13 font-medium tabular-nums">                                {row.amount}                            </span>                            <Tooltip>                                <TooltipTrigger                                    render={                                        <Button                                            variant="ghost"                                            size="icon-sm"                                            aria-label={`Archive ${row.id}`}                                            className="text-muted-foreground"                                            onClick={() => archive(row)}                                        />                                    }                                >                                    <ArchiveIcon aria-hidden="true" />                                </TooltipTrigger>                                <TooltipContent>Archive</TooltipContent>                            </Tooltip>                        </li>                    ))}                </ul>            ) : (                <div className="flex flex-col items-center gap-2 px-4 py-8 text-center">                    <p className="text-13 font-medium">No drafts</p>                    <Button                        variant="outline"                        size="sm"                        onClick={() => setRows(all)}                    >                        <RotateCcwIcon                            data-icon="inline-start"                            aria-hidden="true"                        />                        Reset example                    </Button>                </div>            )}        </section>    );}
  • Make the action reversible in the data first: archive instead of delete, a trash kept for 30 days, a soft void. Then the interface can be fast.
  • The toast is past tense and names the record: INV-20451 archived. Undo puts it back in the same place.
  • Toasts leave after five seconds and pause while hovered or focused. If someone needs longer, the record lives in Archived.

Confirm when undo is impossible#

A payment that has left for the bank can't be un-voided. The dialog says exactly what happens and the button repeats it.

PAY-1182

ACH to Northwind Freight

$18,240.00
Sent to bank, settles Sep 30
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function ConfirmIrreversible() {    const [open, setOpen] = React.useState(false);    const [voided, setVoided] = React.useState(false);    return (        <div className="flex w-full max-w-lg flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex items-start justify-between gap-3">                <div className="min-w-0">                    <p className="font-mono text-xs text-muted-foreground">                        PAY-1182                    </p>                    <p className="text-sm font-semibold">                        ACH to Northwind Freight                    </p>                </div>                <span className="text-sm font-semibold tabular-nums">                    $18,240.00                </span>            </div>            <div className="flex items-center justify-between gap-3 rounded-[10px] bg-muted/70 px-3 py-2.5">                {voided ? (                    <StatusLabel                        tone="danger"                        className="text-muted-foreground"                    >                        Voided                    </StatusLabel>                ) : (                    <StatusLabel                        tone="primary"                        className="text-muted-foreground"                    >                        Sent to bank, settles Sep 30                    </StatusLabel>                )}                {voided ? (                    <Button                        variant="ghost"                        size="sm"                        onClick={() => setVoided(false)}                    >                        Reset example                    </Button>                ) : (                    <Button                        variant="destructive"                        size="sm"                        onClick={() => setOpen(true)}                    >                        Void payment                    </Button>                )}            </div>            <ConfirmDialog                open={open}                onOpenChange={setOpen}                title="Void payment PAY-1182?"                description="The $18,240.00 ACH to Northwind Freight is recalled from the bank, and INV-20417 goes back to unpaid. This can't be undone."                confirmLabel="Void payment"                onConfirm={async () => {                    await new Promise((resolve) => setTimeout(resolve, 700));                    setVoided(true);                    toast.add({                        title: "PAY-1182 voided",                        description:                            "INV-20417 is unpaid again and back in the queue.",                    });                }}            />        </div>    );}

Type to confirm when the loss is large#

Deleting a supplier takes its invoices, remittances and W-9 with it. Typing the name proves the person is looking at the right record. The button stays disabled until it matches.

Halcyon Packaging

Supplier since March 2023

Invoices
14
Remittances
126
Paid to date
$412,906.10
import { Button } from "@oration/canon/components/button";import { ConfirmDialog } from "@oration/canon/components/confirm-dialog";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { toast } from "@oration/canon/components/toast";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function TypeToConfirm() {    const [open, setOpen] = React.useState(false);    const [deleted, setDeleted] = React.useState(false);    if (deleted) {        return (            <div className="flex w-full max-w-lg flex-col items-center gap-2 rounded-xl bg-card px-4 py-8 text-center shadow-border">                <p className="text-13 font-medium">                    Halcyon Packaging was deleted                </p>                <Button                    variant="outline"                    size="sm"                    onClick={() => setDeleted(false)}                >                    <RotateCcwIcon                        data-icon="inline-start"                        aria-hidden="true"                    />                    Reset example                </Button>            </div>        );    }    return (        <div className="flex w-full max-w-lg flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex items-center gap-3">                <MonogramTile name="Halcyon Packaging" color="teal" size="lg" />                <div className="min-w-0 flex-1">                    <p className="text-sm font-semibold">Halcyon Packaging</p>                    <p className="text-xs text-muted-foreground">                        Supplier since March 2023                    </p>                </div>            </div>            <dl className="grid grid-cols-3 gap-2 rounded-[10px] bg-muted/70 px-3 py-2.5 text-13">                <div>                    <dt className="text-xs text-muted-foreground">Invoices</dt>                    <dd className="font-medium tabular-nums">14</dd>                </div>                <div>                    <dt className="text-xs text-muted-foreground">                        Remittances                    </dt>                    <dd className="font-medium tabular-nums">126</dd>                </div>                <div>                    <dt className="text-xs text-muted-foreground">                        Paid to date                    </dt>                    <dd className="font-medium tabular-nums">$412,906.10</dd>                </div>            </dl>            <div className="flex justify-end">                <Button variant="destructive" onClick={() => setOpen(true)}>                    Delete supplier                </Button>            </div>            <ConfirmDialog                open={open}                onOpenChange={setOpen}                title="Delete supplier Halcyon Packaging?"                description="Their 14 invoices, 126 remittances and W-9 are deleted with them, and open invoices drop out of Thursday's run. This can't be undone."                confirmLabel="Delete supplier"                confirmText="Halcyon Packaging"                onConfirm={async () => {                    await new Promise((resolve) => setTimeout(resolve, 800));                    setDeleted(true);                    toast.add({ title: "Halcyon Packaging deleted" });                }}            />        </div>    );}

The typed check is case-sensitive and gives no hint, so halcyon packaging keeps the button disabled without saying why. Recorded on Confirm dialog.

Hold to confirm#

When the action lives in a panel someone is watching, a dialog would cover the thing they're watching. A two-second hold asks for intent without leaving the panel. Release early and it snaps back.

RUN-0932

Payment run for Thursday, October 1

Sending
12 of 57 sent, $48,102.40 so far$241,908.62
import { Button } from "@oration/canon/components/button";import { HoldButton } from "@oration/canon/components/hold-button";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function HoldToConfirm() {    const [cancelled, setCancelled] = React.useState(false);    return (        <div className="flex w-full max-w-lg flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex items-start justify-between gap-3">                <div className="min-w-0">                    <p className="font-mono text-xs text-muted-foreground">                        RUN-0932                    </p>                    <p className="text-sm font-semibold">                        Payment run for Thursday, October 1                    </p>                </div>                {cancelled ? (                    <StatusLabel                        tone="danger"                        className="text-muted-foreground"                    >                        Cancelled                    </StatusLabel>                ) : (                    <StatusLabel                        tone="primary"                        pulse                        className="text-muted-foreground"                    >                        Sending                    </StatusLabel>                )}            </div>            <div className="flex items-center justify-between gap-3 rounded-[10px] bg-muted/70 px-3 py-2.5 text-13">                <span className="text-muted-foreground tabular-nums">                    {cancelled                        ? "0 of 57 sent"                        : "12 of 57 sent, $48,102.40 so far"}                </span>                <span className="font-medium tabular-nums">$241,908.62</span>            </div>            <div className="flex justify-end">                {cancelled ? (                    <Button                        variant="ghost"                        size="sm"                        onClick={() => setCancelled(false)}                    >                        Reset example                    </Button>                ) : (                    <HoldButton                        holdingLabel="Keep holding to cancel"                        onConfirm={() => {                            setCancelled(true);                            toast.add({                                title: "RUN-0932 cancelled",                                description:                                    "The 45 unsent payments stay approved for the next run.",                            });                        }}                    >                        Hold to cancel run                    </HoldButton>                )}            </div>        </div>    );}

Wording#

A confirmation is read once, quickly. Every word has to point at the specific loss.

Writing a confirmation
PartRuleExample
TitleA question with the verb and the specific record.Delete supplier Halcyon Packaging?
DescriptionWhat is lost, who it affects, and whether it can be undone. One or two sentences.Their 14 invoices, 126 remittances and W-9 are deleted with them. This can't be undone.
ConfirmThe verb and the noun again, in the red tint.Delete supplier
CancelCancel, or Keep editing when the loss is unsaved work. Focus starts here.Cancel
Toast afterPast tense, names the record, offers Undo when it can.PAY-1182 voided

Delete supplier Halcyon Packaging?

Their 14 invoices and 126 remittances are deleted with them. This can't be undone.

Do. Name the record in the title and repeat the verb on the button.

Are you sure?

This action cannot be undone.

Don't. Ask Are you sure? and confirm with OK. Nothing says what will be lost, and the button is the indigo one.

Placement and color#

Destructive actions sit last and look different, so nobody hits one on the way to something else.

How destructive actions look
WhereTreatment
Buttonvariant="destructive": bg-destructive/10 with Signal Red text, /20 on hover (/20 and /30 in dark). Never solid.
Menu itemvariant="destructive": red text and icon, a 10% red highlight. Last in the menu, after a separator.
Hold buttonThe same red tint at rest. While held, a solid red fill sweeps across it over two seconds; it is the only solid red in the system and exists only while you hold.
Dialog footerCancel on the left, the destructive confirm on the right. Neither is indigo.
SettingsWorkspace and account deletion sit in the last section of the page, after everything else.
Do. Use the red tint and keep the filled indigo button for the action the view exists for.
Don't. Fill the button solid red. It shouts louder than the primary action and reads as an alarm.

Accessibility#

Confirmation has to work the same for keyboard and screen reader users.

  • Confirm dialogs are alert dialogs with a title and description. Focus starts on Cancel, or on the name field in the typed variant.
  • While onConfirm is pending, the dialog stays open and both buttons disable. Escape and the backdrop don't close it mid-request.
  • The Undo in a toast is a real button. Toasts pause while focused, so reaching Undo by keyboard doesn't race the timer.
  • Hold buttons work with Enter and Space held down. Their label has to say Hold to, because nothing announces the hold starting.
  • Icon-only destructive or archive buttons carry an aria-label with the record (Archive INV-20451) and a tooltip.
  • Red is never the only signal: the verb in the label says what will happen.
Keyboard interactions
KeysAction
EscCloses a confirm dialog without acting, unless it is pending.
EnterIn the typed variant, confirms once the name matches.
SpaceHeld on a hold button, confirms after the duration. Releasing cancels.

Components involved#

The parts this pattern is built from.

Components involved
ComponentRole here
ToastPast-tense confirmation with Undo.
Confirm dialogIrreversible actions, with confirmText for the typed variant.
Hold buttonSustained-press confirmation inside live panels.
ButtonThe destructive red tint.
Dropdown menuDestructive items last, after a separator.
Alert dialogThe primitive under Confirm dialog, for custom layouts.