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.
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.
<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.
| 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.
| 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(oraria-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"oraria-modalon 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. UseshowModal()for a true modal.