The dialog for high-stakes decisions: role="alertdialog", mandatory aria-describedby, focus on the safe default, and the type-to-confirm pattern for irreversible actions — in Vanilla and React.
WCAG: 2.1.1 · 2.1.2 · 2.4.3 · 4.1.2 · 3.3.4
Level: Advanced
Icons: lucide-react (AlertTriangle)
1
What we are building
A modal variant designed for destructive confirmations and critical alerts.
What we are building
An alert dialog is a modal that demands a decision about a potentially destructive action. It uses role="alertdialog" (not just dialog) so screen readers announce it with more urgency, and it must link to a description via aria-describedby.
Every rule from Chapter 9 (focus trap, Escape, return focus, inert background) still applies — plus one new rule: initial focus lands on the safe default, not on the destructive button. The user should never confirm destruction by accidentally pressing Enter.
2
The 4 real-world variants
Switch between the inaccessible version and the accessible fix.
The 4 real-world variants
Variant A
Destructive confirm (delete)
Preferred
Live example
Focus lands on Cancel — the safe default. User must consciously choose destruction.
HTML · Accessible
<!-- role="alertdialog" + aria-describedby + focus on Cancel -->
<div
role="alertdialog"
aria-labelledby="del-title"
aria-describedby="del-desc"
>
<h3 id="del-title">Delete account?</h3>
<p id="del-desc">
This permanently deletes your account and all projects.
This action cannot be undone.
</p>
<button type="button" autofocus>Cancel</button> <!-- safe default -->
<button type="button" class="danger">Yes, delete</button>
</div>
role=alertdialog + aria-describedby + initial focus on Cancel. The user must consciously choose destruction.
Variant B
window.confirm() replacement
Legacy fix
Live example
Native confirm() is one line but you lose styling, SR consistency, and modern browsers can mute it.
JAVASCRIPT · Accessible
// Real alertdialog you control fully
function openDiscardAlert(trigger) {
previousTrigger = trigger;
renderDialog({
role: 'alertdialog',
labelledBy: 'cr-title',
describedBy: 'cr-desc',
title: 'Discard draft?',
body: 'Your unsaved changes will be lost. You can\'t get them back.',
cancel: { label: 'Keep editing', autoFocus: true },
confirm: { label: 'Discard', danger: true, onClick: discardDraft },
});
}
Native confirm() can be muted by modern browsers and cannot be styled. Replace with a real alertdialog you control.
Variant C
Discard changes
In-app
Live example
HTML · Accessible
<!-- Intercept client-side navigation with an in-app alertdialog -->
<div
role="alertdialog"
aria-labelledby="dc-title"
aria-describedby="dc-desc"
>
<h3 id="dc-title">Unsaved changes</h3>
<p id="dc-desc">You have unsaved changes. Leaving now will discard them.</p>
<button type="button" autofocus>Keep editing</button>
<button type="button">Discard</button>
</div>
<!-- JS: on link click, if dirty, show alertdialog instead of navigating.
On "Keep editing" focus returns to the input the user was in. -->
Intercept client-side navigation when a form is dirty. Focus returns to the input the user was editing on 'Keep editing'.
Variant D
Type-to-confirm
High stakes
Live example
Confirm button is disabled until the name matches exactly.
HTML · Accessible
<!-- Type-to-confirm: button disabled until the typed name matches -->
<div
role="alertdialog"
aria-labelledby="tc-title"
aria-describedby="tc-desc"
>
<h3 id="tc-title">Delete project "my-prod-db"?</h3>
<p id="tc-desc">
This deletes the project and <strong>all its data</strong> permanently.
Type <code>my-prod-db</code> to confirm.
</p>
<label>
<span class="sr-only">Type the project name to confirm</span>
<input type="text" aria-describedby="tc-desc">
</label>
<button type="button">Cancel</button>
<button
type="button"
class="danger"
disabled
aria-disabled="true"
>I understand, delete</button>
</div>
<!-- JS: enable the button only when input value === project name -->
For truly irreversible actions. Confirm button is disabled until the typed name matches. Used by GitHub, Stripe, etc.
3
Flagship implementation
A reusable AccessibleAlertDialog with safe-default focus and full modal behavior.
import { useState } from "react";
import AccessibleAlertDialog from "./AccessibleAlertDialog";
export default function AccountSettings() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Delete account
</button>
<AccessibleAlertDialog
open={open}
onClose={() => setOpen(false)}
title="Delete account?"
description="This permanently deletes your account and all projects."
confirmLabel="Yes, delete"
danger
initialFocus="cancel"
onConfirm={handleDelete}
/>
</>
);
}
4
Keyboard behavior
Same as modal — plus a focused rule about which button is pre-focused.
Keyboard behavior
Action
Expected result
Open
Focus moves to the SAFE default (usually Cancel), NOT the destructive button
Tab
Cycles inside the alert only (focus trap)
Shift + Tab
Cycles backwards; wraps
Esc
Dismisses WITHOUT confirming; focus returns to trigger
Enter
Activates the focused button — safe default means no accidental delete
5
Screen reader expectations
alertdialog is announced more urgently than dialog.
Screen reader expectations
On open: “Alert, Delete account?, This permanently deletes your account..., Cancel, button”
Focus on Cancel: “Cancel, button”
Tab to confirm: “Yes, delete, button”
Escape: focus returns to the trigger, which announces again
6
WCAG mapping
Why each rule matters here.
WCAG mapping
Success Criterion
Why it matters
2.1.1 Keyboard (A)
Alert must be fully operable with keyboard.
2.1.2 No Keyboard Trap (A)
Escape must always dismiss.
2.4.3 Focus Order (A)
Focus lands on SAFE default; returns to trigger on close.
3.3.4 Error Prevention (AA)
Destructive actions should be reversible, checked, or confirmed. Alert dialog + type-to-confirm fulfils this.
4.1.2 Name, Role, Value (A)
Must expose role=alertdialog + aria-labelledby + aria-describedby.
7
Testing approach
Framework-agnostic patterns. Pick the one that matches your stack.
Testing approach
Pattern: open → assert role=alertdialog + accessible name + description → assert initial focus is Cancel → Escape → trigger has focus and onConfirm NOT called.