Accessibility Developer Kit · Guide 11 / 20

Accessible Alert Dialog

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.

Flagship implementation

AccessibleAlertDialog.jsx
import React, { useEffect, useRef } from "react";
import "./AccessibleAlertDialog.css";

/**
 * AccessibleAlertDialog
 *   - open, onClose: controlled state
 *   - title, description: strings or elements (both required for a11y)
 *   - confirmLabel (default "Confirm")
 *   - cancelLabel  (default "Cancel")
 *   - onConfirm:   callback for the destructive action
 *   - danger:      true to style confirm as destructive
 *   - initialFocus: "cancel" (default, safe) or "confirm"
 */
export default function AccessibleAlertDialog({
  open, onClose, title, description,
  confirmLabel = "Confirm",
  cancelLabel = "Cancel",
  onConfirm,
  danger = false,
  initialFocus = "cancel",
}) {
  const panelRef = useRef(null);
  const cancelRef = useRef(null);
  const confirmRef = useRef(null);
  const prevActive = useRef(null);

  useEffect(() => {
    if (!open) return;
    prevActive.current = document.activeElement;
    (initialFocus === "confirm" ? confirmRef : cancelRef).current?.focus();
  }, [open, initialFocus]);

  useEffect(() => {
    if (open) return;
    prevActive.current?.focus?.();
  }, [open]);

  useEffect(() => {
    if (!open) return;
    function onKey(e) {
      if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
      if (e.key !== "Tab") return;
      const nodes = panelRef.current?.querySelectorAll("button:not([disabled])");
      if (!nodes || nodes.length === 0) return;
      const first = nodes[0], last = nodes[nodes.length - 1];
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault(); last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault(); first.focus();
      }
    }
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open, onClose]);

  if (!open) return null;

  return (
    <div className="alert">
      <div
        ref={panelRef}
        role="alertdialog"
        aria-labelledby="alert-title"
        aria-describedby="alert-desc"
        className="alert__panel"
      >
        <h2 id="alert-title">{title}</h2>
        <p id="alert-desc">{description}</p>
        <div className="alert__actions">
          <button
            ref={cancelRef}
            type="button"
            className="btn btn--ghost"
            onClick={onClose}
          >
            {cancelLabel}
          </button>
          <button
            ref={confirmRef}
            type="button"
            className={danger ? "btn btn--danger" : "btn"}
            onClick={() => { onConfirm?.(); onClose(); }}
          >
            {confirmLabel}
          </button>
        </div>
      </div>
    </div>
  );
}
Usage · AccountSettings.jsx
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

ActionExpected result
OpenFocus moves to the SAFE default (usually Cancel), NOT the destructive button
TabCycles inside the alert only (focus trap)
Shift + TabCycles backwards; wraps
EscDismisses WITHOUT confirming; focus returns to trigger
EnterActivates 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 CriterionWhy 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.

Jest + Testing Library + jest-axe

AccessibleAlertDialog.test.jsx
// Jest + @testing-library/react + jest-axe
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleAlertDialog from "./AccessibleAlertDialog";

expect.extend(toHaveNoViolations);

function Fixture() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Delete</button>
      <AccessibleAlertDialog
        open={open}
        onClose={() => setOpen(false)}
        title="Delete account?"
        description="This cannot be undone."
        confirmLabel="Yes, delete"
        danger
      />
    </>
  );
}

test("exposes role=alertdialog with label + description", async () => {
  const user = userEvent.setup();
  render(<Fixture />);
  await user.click(screen.getByRole("button", { name: "Delete" }));
  const dlg = screen.getByRole("alertdialog", { name: "Delete account?" });
  expect(dlg).toBeInTheDocument();
  expect(dlg).toHaveAccessibleDescription("This cannot be undone.");
});

test("initial focus lands on Cancel (safe default)", async () => {
  const user = userEvent.setup();
  render(<Fixture />);
  await user.click(screen.getByRole("button", { name: "Delete" }));
  expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus();
});

test("has no axe violations", async () => {
  const { container } = render(<Fixture />);
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleAlertDialog.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AccessibleAlertDialog from "./AccessibleAlertDialog";

test("Escape dismisses the alert without confirming", async () => {
  const user = userEvent.setup();
  const onConfirm = vi.fn();
  function F() {
    const [open, setOpen] = useState(true);
    return (
      <AccessibleAlertDialog
        open={open}
        onClose={() => setOpen(false)}
        onConfirm={onConfirm}
        title="X"
        description="Y"
      />
    );
  }
  render(<F />);
  await user.keyboard("{Escape}");
  expect(onConfirm).not.toHaveBeenCalled();
});

Playwright + @axe-core/playwright

alert-dialog.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("alert-dialog page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-alert-dialog");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("delete flow: Cancel is default focus, Escape dismisses", async ({ page }) => {
  await page.goto("/en/accessible-alert-dialog");
  await page.getByRole("button", { name: /delete account/i }).first().click();
  const dlg = page.getByRole("alertdialog", { name: /delete account/i });
  await expect(dlg).toBeVisible();
  await expect(dlg.getByRole("button", { name: /cancel/i })).toBeFocused();
  await page.keyboard.press("Escape");
  await expect(dlg).toBeHidden();
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Uses role="alertdialog" (not plain dialog)
  • Has aria-labelledby (title) AND aria-describedby (message)
  • Initial focus is on the SAFE default (Cancel / Keep editing)
  • Full modal behavior: focus trap, Escape, return focus, inert background
  • Confirm button text is specific ("Yes, delete", not just "OK")
  • Destructive button visually distinct (red/danger)
  • Irreversible actions have a secondary gate (type-to-confirm, re-authenticate, etc.)
  • Never replaces an alert with window.confirm() in critical flows
  • Zero critical / serious axe violations
9

Common mistakes

The alert-dialog bugs we see on nearly every audit.

Common mistakes

  • Auto-focus on the destructive button. A stray Enter press deletes data.
  • Using role="dialog" instead of alertdialog. SR urgency is lost.
  • No aria-describedby. SR reads title only; user doesn’t hear the consequence.
  • Vague button labels ("OK" / "Cancel"). Replace with action verbs ("Delete", "Keep editing").
  • window.confirm() in critical flows. Modern browsers can mute it; styling is impossible.
  • Both buttons styled the same. The destructive one should look dangerous.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo