# Confirmation dialogs that don't read out their question

Source: https://easeweb.dev/learn/confirmation-dialogs
Topics: Dialogs and overlays > Confirmation dialogs say what will happen and start on the safe choice (WCAG 4.1.2, 2.4.3)
The fix: Either open a native dialog with role="alertdialog" and showModal(), with autofocus on Cancel, or give a custom alertdialog the same name, description and starting focus by hand. In both, once the item is gone, focus moves to something that is still on the page.
Test with: keyboard, screen reader, zoom, forced colors
References: https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/ https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/alertdialog_role

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A list of projects has a Delete button on each row. Press Delete beside Spring launch. A dialog appears over the page: "Delete “Spring launch”?", a line saying the project and its 24 files will be deleted and that this can't be undone, and two buttons, Cancel and Delete project. It looks right. But focus is already on Delete project, and a screen reader says "dialog, Delete project, button" and stops there. Press Enter, as you would to move on, and the project is gone. The row went with the Delete button that opened the dialog, so focus has nowhere to return to and is left on the page's body, on nothing at all.

## Why it fails

The dialog has a role but no name: its title is on screen, but nothing links it to the dialog, so a screen reader can announce it only as "dialog". ARIA requires every dialog to have a name, and a nameless one is reported under 4.1.2. The message that says what will happen is not linked either, so it is not read when focus moves in.

After Delete project, the script tries to return focus to the Delete button, but the row has just removed it. Focus falls to the body: a screen reader has nothing to announce, and where the next Tab goes depends on the browser. Losing focus like this fails 2.4.3.

Where focus starts is the rest of the problem. WCAG doesn't say which button a dialog should open on, but the ARIA Authoring Practices say that when the action can't be undone, focus should start on the least destructive one. A confirmation exists to stop a mistake. Starting on Delete project means the Enter press it is meant to catch is the one that goes through.

## Who is affected

Anyone who works the page from the keyboard meets the dialog with focus already on Delete project. Mouse users have to move to a button and choose, so they rarely notice.

## What automation and AI miss

A scan sees `role="dialog"` and `aria-modal="true"` and may report nothing. axe's dialog-name check is a best practice, and it only sees dialogs that are open when the page loads. Neither a scan nor an AI reading the markup knows which button is the destructive one, or what happens to focus after a click that deletes the row it came from. Nothing looks wrong on screen, so a visual review passes it too. An AI asked to "add a confirmation" often writes exactly this one: it looks careful and puts focus on the confirm button.

## Before: a confirmation whose question is never read

```html
<div id="page">
  <h2>Projects</h2>
  <ul>
    <li data-name="Spring launch" data-files="24">
      Spring launch · 24 files <button type="button" class="delete">Delete</button>
    </li>
    …
  </ul>
</div>

<div class="confirm" role="dialog" aria-modal="true" hidden>
  <h2 class="confirm-title">Delete “Spring launch”?</h2>
  <p class="confirm-message">This deletes the project and its 24 files. It can't be undone.</p>
  <button type="button" class="cancel">Cancel</button>
  <button type="button" class="ok">Delete project</button>
</div>
```

```js
const page = document.getElementById('page');
const confirmBox = document.querySelector('.confirm');
let trigger;
function openConfirm(event) {
  trigger = event.currentTarget;
  const { name, files } = trigger.closest('li').dataset;
  confirmBox.querySelector('.confirm-title').textContent = `Delete “${name}”?`;
  confirmBox.querySelector('.confirm-message').textContent =
    `This deletes the project and its ${files} files. It can't be undone.`;
  page.inert = true;
  confirmBox.hidden = false;
  confirmBox.querySelector('.ok').focus();
}
function closeConfirm() {
  confirmBox.hidden = true;
  page.inert = false;
  trigger.focus();
}
for (const button of document.querySelectorAll('.delete')) {
  button.addEventListener('click', openConfirm);
}
confirmBox.querySelector('.ok').addEventListener('click', () => {
  trigger.closest('li').remove();
  closeConfirm();
});
confirmBox.querySelector('.cancel').addEventListener('click', closeConfirm);
confirmBox.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') closeConfirm();
});
```

On screen it matches the fixed versions word for word, and the page behind it is inert and Escape works, so it passes a look and a quick keyboard check. But nothing links the title or the message to the dialog, focus starts on Delete project, and after it, `trigger.focus()` aims at a button that has just been removed.

## After: a native dialog

```html
<div id="page">
  <h2 id="projects-heading" tabindex="-1">Projects</h2>
  <ul>
    <li data-name="Spring launch" data-files="24">
      Spring launch · 24 files <button type="button" class="delete">Delete</button>
    </li>
    …
  </ul>
</div>

<dialog id="confirm-delete" role="alertdialog"
  aria-labelledby="confirm-title" aria-describedby="confirm-message">
  <form method="dialog">
    <h2 id="confirm-title">Delete “Spring launch”?</h2>
    <p id="confirm-message">This deletes the project and its 24 files. It can't be undone.</p>
    <button value="cancel" autofocus>Cancel</button>
    <button value="delete">Delete project</button>
  </form>
</dialog>
```

```js
const dialog = document.getElementById('confirm-delete');
const heading = document.getElementById('projects-heading');
let row;
for (const button of document.querySelectorAll('.delete')) {
  button.addEventListener('click', () => {
    row = button.closest('li');
    const { name, files } = row.dataset;
    document.getElementById('confirm-title').textContent = `Delete “${name}”?`;
    document.getElementById('confirm-message').textContent =
      `This deletes the project and its ${files} files. It can't be undone.`;
    dialog.returnValue = '';
    dialog.showModal();
  });
}
dialog.addEventListener('close', () => {
  // Cancel and Escape: the browser returns focus to the Delete button.
  if (dialog.returnValue !== 'delete') return;
  row.remove();
  heading.focus();
});
```

`role="alertdialog"` tells a screen reader this dialog needs an answer, `aria-labelledby` gives it the title as its name, and `aria-describedby` has the message read with it. `autofocus` puts focus on Cancel, so a stray Enter keeps the project. `showModal()` makes the page inert and closes on Escape. Once the row is gone, focus moves to the list's heading instead of falling to the body.

## After: a custom dialog, when the dialog element is not an option

```html
<div id="page">
  <h2 id="projects-heading" tabindex="-1">Projects</h2>
  <ul>
    <li data-name="Spring launch" data-files="24">
      Spring launch · 24 files <button type="button" class="delete">Delete</button>
    </li>
    …
  </ul>
</div>

<div id="confirm-delete" role="alertdialog" aria-modal="true"
  aria-labelledby="confirm-title" aria-describedby="confirm-message" hidden>
  <h2 id="confirm-title">Delete “Spring launch”?</h2>
  <p id="confirm-message">This deletes the project and its 24 files. It can't be undone.</p>
  <button type="button" id="confirm-cancel">Cancel</button>
  <button type="button" id="confirm-ok">Delete project</button>
</div>
```

```js
const page = document.getElementById('page');
const dialog = document.getElementById('confirm-delete');
const cancel = document.getElementById('confirm-cancel');
const heading = document.getElementById('projects-heading');
let trigger;
function openConfirm(event) {
  trigger = event.currentTarget;
  const { name, files } = trigger.closest('li').dataset;
  document.getElementById('confirm-title').textContent = `Delete “${name}”?`;
  document.getElementById('confirm-message').textContent =
    `This deletes the project and its ${files} files. It can't be undone.`;
  page.inert = true;
  dialog.hidden = false;
  cancel.focus();
}
function closeConfirm(deleted) {
  dialog.hidden = true;
  page.inert = false;
  if (deleted) {
    trigger.closest('li').remove();
    heading.focus();
  } else {
    trigger.focus();
  }
}
for (const button of document.querySelectorAll('.delete')) {
  button.addEventListener('click', openConfirm);
}
cancel.addEventListener('click', () => closeConfirm(false));
document.getElementById('confirm-ok').addEventListener('click', () => closeConfirm(true));
dialog.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') closeConfirm(false);
});
```

This is the "Before" with four changes: a name, a linked message, focus on Cancel, and a place for focus once the row is gone. The dialog must sit outside the element made inert, as it does here.

## Which option to use

Both pass. Use the native `dialog`: the browser makes the page inert, closes on Escape and returns focus after Cancel, so the only code left is the part this guide is about. The custom dialog is the smallest change when a framework or design system already draws its own overlay, as in the "Before". If you use a library's confirm dialog, check that it lets you name both buttons and choose which one gets focus first.

## Implementation decisions

The cost of the action decides what the dialog should look like:

- Can be undone: skip the dialog. Delete at once and offer Undo where the item was, as in [toasts that vanish before you can read or undo them](/learn/toast-timing).
- Can't be undone: confirm, start on Cancel, and say what will be lost, with counts where they help ("its 24 files").
- Can't be undone and affects other people, such as deleting a shared workspace: ask people to type the item's name before the delete button works, and say in the dialog why that button is not available yet.

Write the title and the confirm button as the action and the item, as here: "Delete “Spring launch”?" and "Delete project". The name a screen reader reads is only as good as the title it points at, and "Are you sure?" with "OK" leaves everyone guessing.

`window.confirm()` is modal and read by screen readers, but browsers usually start it on OK and its buttons can only be OK and Cancel, so it can't match these dialogs.

Put Cancel first in the markup if you show it first, so Tab follows the order on screen. If the confirm button is styled red, make sure it doesn't rely on red alone: its label already says "Delete project".

After deleting, the list heading is a safe place for focus. The next row's Delete button is quicker for someone clearing several items, but it is easy to press by mistake, so don't start there. When nothing on the page says the item is gone, add a message that is announced, as in [status messages that are shown but never announced](/learn/status-messages).

For the rest of a modal's behaviour (focus moving in, the page behind it, Escape), see [modals that leave keyboard focus behind them](/learn/modal-focus-management).

## Verify the fix

1. Open the confirmation from the keyboard. Focus must start on Cancel, or on whichever button keeps things as they are.
2. Press Enter straight away. Nothing must be deleted, and focus must be back on the Delete button that opened the dialog.
3. With a screen reader, open the dialog again. It must announce the title ("Delete “Spring launch”?"), the message, and the focused button.
4. Confirm the deletion. Focus must land on the list's heading, or another element that is still on the page, never on the page's body.
5. Open it once more and press Escape: it must close without deleting. Check the dialog at increased zoom and in forced colors.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the shared dialog component changes.

## Limits

This is a starting pattern, not a guarantee for every application. The snippets delete only from the page and leave out the request to the server, its errors, and what happens if it fails. Screen readers differ in how much of an alert dialog they read on their own, so test the complete journey with your target assistive technology.
