Building an accessible modal dialog

Use the native dialog element so focus moves in and back, Escape closes, and the rest of the page is inert.

  • Beginner
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Build a modal with dialog
  • Return focus when it closes
  • Name and describe a dialog
  • Choose between modal and non-modal dialogs

A modal dialog interrupts the page to ask for a decision or show focused content. Built by hand, modals are one of the most error-prone components on the web: focus escapes behind them, screen readers read the page underneath, and keyboard users cannot close them. The native dialog element solves most of this for you.

What an accessible modal must do

The ARIA Authoring Practices Guide (APG) Dialog (Modal) pattern sets out the expected behaviour:

  1. Focus moves into the dialog when it opens, to the first sensible element.
  2. Focus stays inside while it is open. Tab and Shift+Tab cycle through its controls only.
  3. The content behind is inert: it cannot be focused, clicked or read by screen readers.
  4. Escape closes it.
  5. Focus returns to the element that opened it, usually the trigger button.
  6. It has a role and a name: screen readers announce "dialog" and its title.

Why use the dialog element

Calling showModal() on a dialog gives you points 2, 3 and 4 natively, and modern browsers handle 1 and 5 as well. It also renders the dialog in the top layer, above everything else regardless of z-index, and provides a ::backdrop you can style. The dialog element is supported in all current major browsers.

A hand-built div role="dialog" needs a focus trap, inert or aria-hidden on the background, an Escape key handler and focus restoration, all written and maintained by you.

A complete confirmation dialog

HTML
<button type="button" id="open-delete">Delete saved search</button>

<dialog id="confirm-delete" aria-labelledby="confirm-title" aria-describedby="confirm-desc">
  <h2 id="confirm-title">Delete this saved search?</h2>
  <p id="confirm-desc">You will stop getting emails about new books that match it. You cannot undo this.</p>
  <form method="dialog">
    <button type="submit" value="cancel" autofocus>Cancel</button>
    <button type="submit" value="delete">Delete search</button>
  </form>
</dialog>

<p id="result" role="status"></p>
CSS
dialog {
  max-width: min(32rem, calc(100vw - 2rem));
  padding: 1.5rem;
  border: 2px solid #0b0c0c;
  border-radius: 0.5rem;
}

dialog::backdrop {
  background: rgb(0 0 0 / 0.6);
}

dialog form {
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
}
JavaScript
const opener = document.getElementById('open-delete');
const dialog = document.getElementById('confirm-delete');
const result = document.getElementById('result');

opener.addEventListener('click', () => {
  dialog.returnValue = '';
  dialog.showModal();
});

dialog.addEventListener('close', () => {
  if (dialog.returnValue === 'delete') {
    result.textContent = 'Saved search deleted.';
  }
  // Browsers restore focus on close; doing it explicitly is a safe fallback.
  opener.focus();
});

How it meets each requirement:

  • Focus in: showModal() moves focus to the element with autofocus, here Cancel. For a destructive action, start on the least destructive option so an accidental Enter does no harm.
  • Focus contained and background inert: while the dialog is modal, the rest of the document is inert, so Tab cannot reach it and screen readers do not read it.
  • Escape closes: the browser fires a cancel event, then close.
  • Buttons close it without JavaScript: a form with method="dialog" closes the dialog on submit and sets dialog.returnValue to the value of the button pressed.
  • Focus returns to the opener in the close handler, which runs whichever way the dialog closed.
  • Name and description: the dialog role is implicit. aria-labelledby names it from the heading and aria-describedby adds the warning, so screen readers announce something like "Delete this saved search?, dialog. You will stop getting emails…".

Resetting returnValue before each opening matters: closing with Escape does not change it, so an old value could otherwise be read again.

Use showModal(), not show(), for modals. show() opens a non-modal dialog: the page behind stays interactive and Escape does not close it. Do not use the open attribute to display a modal either, for the same reason.

Choosing where focus goes first

The APG gives practical guidance:

  • In most dialogs, focus the first focusable element.
  • If the content is long and the first control is at the bottom, put tabindex="-1" on the heading or an introductory paragraph and give that autofocus, so screen reader users start reading at the top.
  • For a destructive confirmation, focus the least destructive action.

Avoid focusing the close button first in a long dialog; screen reader users then skip the content.

Closing by clicking the backdrop

Some designs close the dialog when users click outside it. Clicks on ::backdrop are reported on the dialog element itself, as are clicks on the dialog's own padding, so check the target and then compare the click position with the dialog's box. The target check also matters for keyboard users: pressing Enter on a button fires a click with coordinates of zero, which would otherwise look like a click outside.

JavaScript
dialog.addEventListener('click', (event) => {
  // Clicks on content target the child element; only the dialog box and backdrop target the dialog.
  if (event.target !== dialog) return;
  const box = dialog.getBoundingClientRect();
  const inside =
    event.clientX >= box.left && event.clientX <= box.right &&
    event.clientY >= box.top && event.clientY <= box.bottom;
  if (!inside) dialog.close();
});

This is an extra, not a replacement for a visible close or cancel button and Escape. Do not add it to dialogs where losing typed data would be a problem.

Close buttons

Every modal needs a visible way to close it. If you use an icon, give it a name.

HTML
<form method="dialog">
  <button type="submit" aria-label="Close">
    <svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 20 20">
      <path d="M4 4l12 12M16 4L4 16" stroke="currentColor" stroke-width="2"/>
    </svg>
  </button>
</form>

Alert dialogs

When a dialog interrupts the user with an urgent message that needs a response, such as "Your session will end in 2 minutes", the APG recommends role="alertdialog". Some screen readers announce it more urgently. The behaviour is the same, and it still needs a name and a description.

HTML
<dialog id="timeout" role="alertdialog" aria-labelledby="timeout-title" aria-describedby="timeout-desc">
  <h2 id="timeout-title">Your session is about to end</h2>
  <p id="timeout-desc">You will be signed out in 2 minutes to protect your information.</p>
  <form method="dialog">
    <button type="submit" value="stay" autofocus>Stay signed in</button>
    <button type="submit" value="leave">Sign out</button>
  </form>
</dialog>

A time limit like this also relates to 2.2.1 Timing Adjustable (A), which requires that users can turn off, adjust or extend most time limits. Warning users and letting them extend with a simple action is one way to meet it.

Common mistakes

  • Opening a modal on page load or on a timer. Unexpected modals disorient screen reader and magnifier users.
  • Making the dialog taller than the viewport with no scrolling. Let the dialog scroll, and check it at 400% zoom.
  • Placing important content such as error messages outside the dialog, where inert content cannot be reached.
  • Forgetting to remove scroll locks or other page changes when it closes.
  • Nesting modals. Avoid opening a modal from a modal where you can.

Testing checklist

  1. Open the dialog with the keyboard. Does focus land inside, on a sensible element?
  2. Press Tab repeatedly. Does focus stay within the dialog and the browser interface?
  3. With a screen reader, is "dialog" and its name announced? Can you read the page behind? You should not be able to.
  4. Press Escape. Does it close, and does focus return to the trigger?
  5. Close it with each button. Does focus return each time?

Summary

  • Build modals with dialog and showModal(): the page behind becomes inert, focus is contained and Escape closes it.
  • Use autofocus to choose the first focus, and the least destructive action for confirmations.
  • Name the dialog with aria-labelledby and describe it with aria-describedby.
  • Return focus to the trigger on close, and reset returnValue before reopening.
  • Always provide a visible close or cancel button; backdrop clicks are only an extra.
  • Use role="alertdialog" for urgent interruptions that need a response.