PLAINFINCH learn by building

Dialogs

You will make a cancel dialog hold keyboard focus, close with Escape and have a name screen readers can say.

What makes a dialog hard to use without a mouse?

A dialog is a small window inside the page that asks for attention: confirm a cancellation, sign in, choose a delivery slot. A sighted mouse user sees it appear over a dimmed page and clicks a button. A keyboard user needs focus to move into it, or their next Tab lands somewhere behind it. A screen reader user needs to hear that a dialog opened, what it is called, and what it asks.

On the starting page, press Cancel booking and open the Keyboard view. Focus moves into the dialog, which is a good start. Press Tab twice and focus leaves the dialog and reaches Cancel booking again, behind the box. Press Escape and nothing happens. The Screen reader view says “dialog, unlabeled”. Each of these is a real barrier, and each comes from one line of code.

What does the `<dialog>` element do for you?

The <dialog> element has been supported in every major browser since 2022. It has the dialog role, it is hidden until opened, and it gives you two ways to open it. show() opens it as a non-modal dialog: the rest of the page stays usable, which suits a small panel that sits beside the content. showModal() opens it as a modal dialog, and that is what most confirmation boxes should be.

A modal dialog opened with showModal() gets a lot of behaviour for free. The browser draws it on the top layer above everything else, with a ::backdrop you can style. It makes the rest of the page inert, so Tab, clicks and screen reader browsing cannot reach it. It moves focus to the first focusable element inside, or to an element with the autofocus attribute. It closes on Escape. When it closes, focus goes back to the element that was focused before it opened.

Building the same thing from a <div> takes a focus trap, a key handler, aria-modal, inert on every sibling, and careful focus return. Most hand-built versions miss at least one piece.

<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Cancel this booking?</h2>
  ...
</dialog>
<script>
  cancelBtn.addEventListener('click', () => dlg.showModal());
</script>

How does a dialog get its name?

A dialog does not take its name from its content. A button inside reads its own text, and a heading reads its text, but a dialog is a container, and the browser will not guess which of its words are the title. Without a name, screen readers announce “dialog” and go straight to the first focused element, so the user hears “dialog, Keep booking, button” and has to work out the question from the buttons.

Point aria-labelledby at the id of the visible heading. The heading stays visible for sighted users, and its text becomes the dialog’s name. Use aria-label only when there is no visible title to point at. If the dialog has a sentence explaining the choice, aria-describedby on that paragraph makes many screen readers read it as well, which helps with confirmation boxes.

The Checks view flags an open dialog with no name as dialog-name. The Screen reader view shows the name on the dialog’s first line.

Where should focus go when the dialog opens and closes?

When the dialog opens, focus should land somewhere useful inside it. By default showModal() picks the first focusable element. For a confirmation that destroys something, such as cancelling a booking, it is kinder to put focus on the safe choice, so an accidental Enter does no harm. Add autofocus to that button, or order the buttons so the safe one comes first, as the demo does with Keep booking.

When it closes, focus must return to the control that opened it. This is 2.4.3 Focus Order in practice: if focus disappears to the top of the page, a keyboard user has to Tab all the way back, and a screen reader user loses their place entirely. The native dialog restores focus on close. If you build your own, or if the button that opened it no longer exists, move focus yourself to the next sensible place.

Check this in the Keyboard view. Press Cancel booking, then Keep booking, and look at which element is focused.

  • On open: focus moves inside, to the first control or the safest one
  • While open: Tab and Shift+Tab stay inside the dialog
  • On Escape: the dialog closes, like pressing Cancel
  • On close: focus goes back to the button that opened it
FigurePressing Cancel booking calls showModal. Focus lands on Keep booking inside the dialog named Cancel this booking?, Tab stays between Keep booking and Yes, cancel it, and Escape returns focus to Cancel booking.Pressing Cancel booking calls showModal. Focus lands on Keep booking inside the dialog named Cancel this booking?, Tab stays between Keep booking and Yes, cancel it, and Escape returns focus to Cancel booking.
Follow the focus: into the dialog on the safe Keep booking button, round its own buttons while the page behind is inert, then back to Cancel booking when it closes.

What does inert mean for the page behind?

Inert content cannot be focused, clicked or found by assistive technology. A modal dialog makes everything outside it inert while it is open. That is what stops Tab wandering behind the box, and what stops a screen reader user reading the page underneath with arrow keys and getting confused about where they are.

You can also set the inert attribute yourself on any element, for example on a page behind a custom drawer menu. It is supported in all major browsers. Use it carefully: inert content looks the same as normal content unless you style it, and a sighted keyboard user who cannot reach something they can see will think the page is broken.

In the Keyboard view, open the modal and count the Tab stops. With showModal() there are only the dialog’s own buttons.

When is a dialog the wrong choice?

Dialogs interrupt. Every one asks the user to stop, read and decide. For a message that needs no decision, such as “Saved”, a status message in the page is gentler. For extra detail, a disclosure that expands in place keeps people in the flow. For long forms, a separate page is easier to zoom, bookmark and come back to.

Dialogs that open on their own are worst. A newsletter box that appears five seconds after the page loads pulls focus away from whatever the person was reading, and screen reader users may not realise the page behind is still there. If you must show one, wait for a clear action, give it an obvious close button and let Escape close it.

On small screens, make sure the dialog fits at 320 CSS pixels wide and scrolls inside if its content is long. A dialog with a close button off screen is a trap for anyone zoomed in.

How do you close a dialog from your own buttons?

Call dlg.close() from the button’s click handler, as the demo does. You can also put the buttons in a <form method="dialog">: submitting that form closes the dialog without any script, and the dialog’s returnValue holds the value of the button that was pressed. That is handy for yes and no choices.

Give every dialog a visible way out. Escape is invisible to people who don’t know the convention, and touch screen users have no Escape key. A close button with only × or an icon inside needs an accessible name such as aria-label="Close", or a screen reader says “button, times” or nothing at all.

Some recent browsers also support a closedby attribute that lets clicks on the backdrop close the dialog. Treat it as a convenience on top of a real button, never a replacement.

<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Cancel this booking?</h2>
  <form method="dialog">
    <button value="keep">Keep booking</button>
    <button value="cancel">Yes, cancel it</button>
  </form>
</dialog>

What do AI tools get wrong when they write dialogs?

Generated code very often builds the dialog from a <div class="modal"> with role="dialog" and stops there. There is no focus trap, no Escape handling, and focus stays on the button behind the overlay. Sometimes it adds aria-modal="true", which tells screen readers to ignore the rest of the page while the keyboard can still reach it, a confusing mix.

Other patterns to look for: calling show() where showModal() was meant, a close button with only “×” in it, a dialog with no name, and code that sets display: none on close without returning focus. Some tools write their own Tab trap and break Shift+Tab in the process.

When you review one, open it with the keyboard only. Press Tab until you loop, press Shift+Tab, press Escape, and watch where focus lands after closing. If any step feels wrong, switch to <dialog> with showModal().

Field notes

  • A modal dialog should take focus, keep it inside and give it back on close.
  • Opening a <dialog> with showModal() adds Escape and makes the page behind inert.
  • A dialog gets its name from aria-labelledby pointing at its heading.
  • Use a dialog only when the person needs to stop and decide.

Easy to get wrong

A div with a role announces as a dialog and does nothing else. The native element traps focus, closes on Escape and restores focus when it closes.

show() opens a non-modal dialog: the page behind stays reachable and Escape does not close it. Confirmation boxes should be modal.

A dialog is not named by its content. Without aria-labelledby a screen reader announces only “dialog”.

A symbol on its own gives a screen reader nothing useful to say, and voice control users cannot name it.

Questions people ask

Do I still need `aria-modal="true"` on a native dialog?

No. A <dialog> opened with showModal() is exposed as modal by the browser. aria-modal is for custom dialogs built from other elements, and even there it only affects screen readers, not the keyboard.

Should the dialog’s heading be an `<h2>`?

An <h2> works well in most pages, since the dialog sits under the page’s <h1>. The heading must exist, be visible and be the target of aria-labelledby.

What about `role="alertdialog"`?

Use it for urgent dialogs that need an immediate answer, such as “Your changes will be lost”. Add it to the <dialog> element and give it a name and a description. Most dialogs are ordinary dialogs.

Can I prevent Escape from closing a dialog?

You can listen for the cancel event and call preventDefault(), but do it rarely. People rely on Escape to get out, and a dialog that refuses can feel like a trap. If closing would lose work, ask them to confirm instead.

Keep going