Skip to content

Pattern

Confirmation dialog

Asking someone to confirm before something irreversible happens. Confirmation is a cost paid by every user to protect against a mistake a few will make — so it has to be spent carefully, and it has to actually inform.

Built fromDialogButtonInputFormField

The patternPermalink to this section

Trigger the dialog and read it: the title names the object, the description names the consequence, and both buttons name their outcome.

StructurePermalink to this section

Four decisions, each of which changes whether the dialog is read or dismissed reflexively.

  • Title — names the specific object: “Delete “Q3 marketing site”?”, not “Delete workspace?”.
  • Description — states what is lost, with a count, and whether it can be undone.
  • Cancel — labelled with what it preserves: “Keep workspace”. It is the safe default and gets initial focus.
  • Confirm — repeats the verb from the trigger: “Delete workspace”. Destructive variant, right-hand side.

When confirmation is the wrong answerPermalink to this section

Most actions do not need a dialog. Undo is almost always better: it costs nothing when the action was intended, and fixes it completely when it was not.

Archived “legacy-webhooks”

Prefer undo to confirmation

Reach for a confirmation dialog only when the action is genuinely irreversible, affects other people, or costs money. Everything else should just happen, with an undo affordance.

High-consequence actionsPermalink to this section

For actions affecting live systems or other people, require the name to be typed. The friction is the point — it makes accidental confirmation impossible.

Use this sparingly

Type-to-confirm is the strongest tool available. Applied to routine actions it becomes a ritual people copy-paste through, and then it protects nothing.

Focus managementPermalink to this section

Where focus lands decides what happens when someone presses Enter out of habit.

  • Focus starts on the cancelling action, so a reflexive Enter preserves rather than destroys.
  • Focus is trapped inside the dialog — Tab never reaches the page behind it.
  • Escape closes the dialog and cancels, returning focus to the trigger.
  • After a successful destructive action, move focus to a stable element — the list heading, not the row that no longer exists.
  • Never auto-focus the destructive button, however clearly it is labelled.
  • For type-to-confirm, focus the input; the confirm button stays disabled until the text matches.

Do and don'tPermalink to this section

Doname the outcome on both buttons

Buttons that make sense read alone survive the way people actually scan a dialog.

Don'tuse Yes and No

“Yes” only means something if the title was read carefully, which is exactly what cannot be assumed.

Delete “Q3 marketing site”?

Removes 12 pages, 4 members and all history.

Doquantify the loss

Concrete numbers turn an abstract warning into a decision someone can actually make.

Archive this project?

You can unarchive it later.

Don'tconfirm reversible actions

If the description has to reassure that it is undoable, the dialog should not exist. Archive it and offer undo.

Code examplePermalink to this section

app/workspace/confirm-delete.tsx
"use client";

import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@borneo/react";
import { Trash2 } from "lucide-react";
import { useState } from "react";

export function ConfirmDelete({ workspace, onDelete }) {
  const [open, setOpen] = useState(false);
  const [pending, setPending] = useState(false);

  async function confirm() {
    setPending(true);
    await onDelete(workspace.id);
    setPending(false);
    setOpen(false);
  }

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="destructive" leadingIcon={<Trash2 />}>
          Delete workspace
        </Button>
      </DialogTrigger>

      <DialogContent size="sm">
        <DialogHeader>
          {/* Names the object, not just the action */}
          <DialogTitle>Delete “{workspace.name}?</DialogTitle>
          <DialogDescription>
            This permanently removes {workspace.pageCount} pages,{" "}
            {workspace.memberCount} members and all deployment history.
            It cannot be undone.
          </DialogDescription>
        </DialogHeader>

        <DialogFooter>
          {/* Cancel is first in the DOM, so it receives initial focus */}
          <DialogClose asChild>
            <Button variant="ghost">Keep workspace</Button>
          </DialogClose>

          <Button variant="destructive" loading={pending} onClick={confirm}>
            Delete workspace
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

Responsive behaviorPermalink to this section

Below 640px the dialog becomes a bottom sheet and the buttons stack, with the confirming action on top and cancel beneath it — both within thumb reach, and in the order people read.

AccessibilityPermalink to this section

  • The dialog is role="dialog" with aria-modal, named by its title through aria-labelledby.
  • The description is linked with aria-describedby, so the consequence is announced with the title.
  • Focus is trapped while open and returns to the trigger on close, whatever closed it.
  • Initial focus is on the cancelling action — the safe default.
  • Escape cancels, matching what the close button and the overlay do.
  • Destructive intent is carried by the button's label as well as its colour.
  • After the action completes, announce the result in a live region so it is not silent for screen reader users.