Modal dialog

A modal dialog is a window that opens over the page, takes focus, and traps it there until the user dismisses the dialog — everything behind it is inert. Use it for a focused task or a confirmation that must block the rest of the page.

You almost never need to build this by hand. Prefer the native <dialog> element opened with showModal(): you get focus handling, Esc to close, the browser’s top layer, and a ::backdrop for free — all the parts that custom modals routinely get wrong. The script on this page adds only the open/close wiring and, for cross-browser safety, restoring focus to the trigger on close.

Live demo

Open the dialog, then try Tab and Shift + Tab (focus stays inside), and Esc to close. On close, focus returns to the “Open dialog” button you started from.

Rename project

Give this project a new name. Changes apply immediately and everyone with access will see the new name.


When to use

Use a modal for blocking, focused tasks

Reach for a modal dialog when a short, focused task or a confirmation genuinely must interrupt the page — “Discard unsaved changes?”, “Rename this item”, “Confirm delete”. The point of a modal is to stop everything else until the user responds.

Don’t overuse it. If the content doesn’t need to block the page, show it inline, on its own page, or in a non-modal popover instead. Modals interrupt, so each one should earn that interruption.

Markup

A trigger button references the dialog by selector, and the dialog names itself with aria-labelledby. No role or aria-modal is written by hand — the native element and showModal() supply those.

modal-dialog.html
<button type="button" data-dialog-open="#confirm-dialog">
  Delete project
</button>

<dialog id="confirm-dialog" data-dialog aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete project?</h2>
  <p>This permanently removes the project and all of its data.</p>
  <button type="button" data-dialog-close>Cancel</button>
  <button type="button">Delete</button>
</dialog>

Keyboard interactions

All of the following come from the native <dialog> opened with showModal(). The only behaviour the script guarantees explicitly is returning focus to the trigger on close.

Keyboard interactions
Key Result
Esc Closes the dialog. Provided natively by showModal().
Tab Moves to the next focusable control inside the dialog; focus cannot leave the dialog while it is open (native focus trap).
Shift + Tab Moves to the previous focusable control inside the dialog, cycling within it in reverse.
Enter / Space Activates the focused button — including the close button, which calls dialog.close().
On close Focus returns to the trigger button that opened the dialog, however the dialog was closed.

ARIA roles, states & properties

The native element provides the role and modal state automatically. You only supply the accessible name. Avoid adding role="dialog" or aria-modal yourself — duplicating them can conflict with the browser.

ARIA roles, states & properties
Attribute / role Where Purpose
role="dialog" On the <dialog> (implicit) The element’s built-in role — do not add it by hand.
aria-modal="true" On the <dialog> (set by the browser) Applied automatically when opened with showModal(); tells assistive tech that content outside the dialog is inert.
aria-labelledby On the <dialog> (you add this) Points at the dialog’s heading id to give the dialog an accessible name. Use aria-label only if there is no visible title.
aria-describedby On the <dialog> (optional) Reference body text when a short description should be announced with the dialog’s name.

Common mistakes

What custom modals get wrong

  • No focus trap. A hand-rolled modal that lets Tab escape into the page behind it leaves screen reader and keyboard users lost. The native dialog traps focus for you — use it.
  • Focus not restored on close. If focus drops to the top of the document when the dialog closes, the user loses their place. Always send focus back to the control that opened the dialog.
  • No accessible name. A dialog with no aria-labelledby (or aria-label) is announced as just “dialog”, with no clue what it’s for. Name it from its visible heading.
  • Re-declaring native semantics. Adding role="dialog" or aria-modal on top of a real <dialog> is redundant and can clash with what the browser already exposes.
  • Opening with open / show(). Both open the dialog non-modally — no focus trap, no backdrop, no inert background. Use showModal() for a true modal.