Confirming and Undoing Destructive Row Actions
Permalink to "Confirming and Undoing Destructive Row Actions"A destructive row action — delete, archive, revoke, cancel an order — is the highest-stakes control in a data table. Two protections are common: a confirmation dialog before the action, or an undo after it. Each has an accessibility failure mode. Confirmations fail when they are vague (“Are you sure?”) or put focus on the destructive button, so an extra Enter deletes. Undo fails when it lives in a toast that disappears in four seconds, somewhere a keyboard or screen reader user cannot reach in time.
This page covers choosing between them and implementing each so that keyboard and screen reader users are as protected as everyone else. It belongs to row actions & context menus.
Spec reference
Permalink to "Spec reference"SC 3.3.4 Error Prevention (Legal, Financial, Data) (Level AA) applies to pages that cause legal commitments or financial transactions, modify or delete user-controllable data in data storage systems, or submit test responses. At least one of: the submission is reversible, checked for errors with a chance to correct, or confirmed before finalising. Deleting records in a data application falls squarely under “delete user-controllable data”.
SC 2.2.1 Timing Adjustable (Level A) applies to time limits — an undo that expires is a time limit on the ability to reverse. SC 2.2.1’s exception for “real-time” events does not cover it; either make it long, let users extend it, or keep undo available without a timer.
SC 4.1.3 Status Messages covers the “Deleted. Undo” message. Dialogs follow SC 2.4.3 and SC 4.1.2.
When to confirm — and when to offer undo
Permalink to "When to confirm — and when to offer undo"Undo is better for the common case: archive, move, remove from list, soft delete. It does not interrupt the user’s flow, it satisfies SC 3.3.4 through reversibility, and it protects against mistakes the user only notices later.
Confirmation is required when the action is genuinely irreversible — permanent deletion, sending money, revoking access that cannot be re-granted — or when its effects spread beyond the row (deleting a customer deletes their invoices).
Both are reasonable for bulk actions: confirm “Delete 12 invoices?”, then offer undo.
The misapplication to name is a confirmation dialog with generic text — “Are you sure you want to continue?” — and the destructive button focused by default. Users of every kind learn to press Enter through generic dialogs; putting focus on “Delete” turns that habit into data loss.
Annotated code example
Permalink to "Annotated code example"<!-- CONFIRMATION: names the record and the consequence -->
<dialog id="confirm-delete" aria-labelledby="cd-title" aria-describedby="cd-desc">
<h2 id="cd-title">Delete invoice INV-1042?</h2>
<p id="cd-desc">This permanently deletes the invoice and its 3 payment records.
It cannot be undone.</p>
<form method="dialog" class="actions">
<!-- SC 3.3.4: safe choice focused by default -->
<button value="cancel" autofocus>Keep invoice</button>
<button value="delete" class="danger">Delete invoice</button>
</form>
</dialog>
// UNDO: act at once, announce with the key, keep undo reachable
let lastUndo = null;
async function archiveRow(row) {
const id = row.dataset.id;
const target = focusTargetAfterDelete(row); // decide before removing
await api.archive(id);
row.remove();
target.focus();
lastUndo = { run: () => api.unarchive(id).then(() => restoreRow(id)), label: `Archive of ${id}` };
// SC 4.1.3: the message tells keyboard users how to undo without finding the toast
status(`${id} archived. Press Control Z to undo.`);
showToast(`${id} archived`, { action: 'Undo', onAction: undo }); // visible, persistent until dismissed
}
document.addEventListener('keydown', (e) => {
const inText = e.target.closest('input, textarea, [contenteditable="true"]');
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && !inText && lastUndo) {
e.preventDefault(); undo();
}
});
async function undo() {
const u = lastUndo; lastUndo = null;
await u.run();
status(`${u.label} undone.`); // focus goes to the restored row
}
/* The toast persists until dismissed — no four-second timer (SC 2.2.1) */
.toast { position: fixed; inset-block-end: 1rem; inset-inline-start: 1rem; }
The undo key announced in the status message is the important part. A toast’s Undo button is fine for pointer users, but reaching it by keyboard means tabbing to a fixed-position element whose place in the tab order is unclear. A global Ctrl+Z that reverses the last row action — guarded so it never fires in text fields — gives keyboard users the same speed of recovery.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected behaviour | Announcement |
|---|---|---|
| Delete… from row menu | Dialog opens; focus on “Keep invoice” | “Delete invoice INV-1042?, dialog. This permanently deletes… Keep invoice, button” |
Enter immediately |
Cancels; focus back to row menu trigger | Trigger read |
Tab, Enter |
Deletes; focus to next row | “INV-1042 deleted.” |
| Archive (undoable) | Row removed; focus to next row | “INV-1042 archived. Press Control Z to undo.” |
Ctrl+Z |
Row restored; focus on it | “Archive of INV-1042 undone.” |
Integration context
Permalink to "Integration context"Focus after the action follows preserving focus when rows are deleted; after undo, focus goes to the restored row. Confirmation dialogs should be native <dialog> elements, as covered in native dialog versus custom focus traps.
Cell-level edits have their own undo expectations, covered in undo and cancel affordances for inline cell edits. Use one undo stack for both, so Ctrl+Z means the same thing everywhere in the grid.
Gotchas
Permalink to "Gotchas"Toasts with role="alert" and a timer. An assertive, self-dismissing toast interrupts speech and then vanishes before a keyboard user can reach its button. Use a polite status message for the announcement and keep the visible toast until dismissed.
Undo after navigation. If the user leaves the page, the undo should either persist (a “Recently archived” view) or be clearly lost. Silent expiry is the worst option.
Bulk undo. One Ctrl+Z should reverse the whole bulk action, not one row of it.
Design system notes
Permalink to "Design system notes"Provide an action framework rather than ad hoc handlers: each destructive action declares whether it is reversible, its confirmation copy template (“Delete {type} {id}?”), and its inverse. The framework then chooses confirm or undo, generates specific dialog text, focuses the safe option, manages the undo stack and the Ctrl+Z binding, and announces results through the shared status region.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Should destructive actions use a confirmation dialog or undo?
Undo when the application can reverse the action — it protects users without interrupting them. A confirmation when the action is irreversible or affects more than the row. SC 3.3.4 accepts either reversibility or confirmation.
Which button should be focused in a delete confirmation?
The safe one — Cancel, or better a specific “Keep invoice”. Focusing the destructive button turns an accidental Enter into data loss.
Is an undo toast accessible?
Only if keyboard and screen reader users can use it in time. Announce the action with the undo key in a polite status message, offer a keyboard shortcut for undo, and keep the toast until it is dismissed rather than on a short timer.
Does a disappearing undo button fail WCAG?
A short undo window is a time limit, which SC 2.2.1 requires to be adjustable. Keeping undo available until the next destructive action, or until dismissed, avoids the problem.
Related
Permalink to "Related"- Preserving focus when rows are deleted — where focus goes after
- Native dialog versus custom traps — the confirmation container
- Undo for inline edits — undo at cell level