Modal dialogs without a library: <dialog>, showModal(), :modal, commandfor and closedby
A modal dialog has a long list of requirements: block interaction with the page behind it, move focus into the dialog and back to the opener afterwards, close on Esc, dim the background, and stay accessible. That used to mean a library. The HTML <dialog> element, opened with showModal(), now does most of it natively, and recent additions remove the last bits of JavaScript.
Every behaviour below was checked in headless Chromium 153, Firefox 155 and WebKit 26.6 using the official Playwright Docker image. Where Playwright's WebKit build differs from shipping Safari, that's noted, using MDN's compatibility data.
The markup: no JavaScript needed to open it
<button commandfor="confirm" command="show-modal">Delete file…</button>
<dialog id="confirm" closedby="any">
<form method="dialog">
<p>Delete <strong>report.pdf</strong>?</p>
<button value="cancel" autofocus>Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>commandfor+command="show-modal"(invoker commands) opens the dialog when the button is clicked, with no click handler.command="close"andcommand="request-close"work the same way. In the tests,closeshut the dialog without acancelevent andrequest-closefired one first. This is supported in Chrome 135, Firefox 144 and Safari 26.2.<form method="dialog">closes the dialog when a button inside it is pressed, and setsdialog.returnValueto that button'svalue.autofocuspicks which element gets focus on open. For a destructive action, put it on the safe choice.closedby="any"also closes on a click outside the dialog ("light dismiss"). Support for this one isn't universal yet, see below.
What the tests reported after clicking "Delete file…", identical in all three engines:
command="show-modal" click → open=true :modal=true focus="Cancel"What showModal() does for you
Opening with showModal() (or command="show-modal") is what makes a dialog modal. show() opens it as an ordinary non-modal box (show() → open=true :modal=false). With a modal dialog open:
- The rest of the page is inert. A click on a button behind the dialog reached it zero times in every engine.
- Esc closes it, firing a
cancelevent and thenclose. - Focus goes back to the opener after closing. The test checked this after Esc and after a form button, in all three engines.
- Tab never reaches the page behind. It isn't a strict trap, though. Chromium and WebKit cycle through the dialog's buttons and then out to the browser's own UI (
Delete, BODY, Cancel, Delete), while Firefox stayed inside. That's intentional: keyboard users can still reach the address bar, which is better for accessibility than trapping the keyboard completely. - The dialog is rendered in the top layer, above everything else, with no
z-indexneeded. It also gets a::backdroppseudo-element you can style.
Escape → open=false, cancel event | close event, returnValue="", focus back on "open"
click "Delete" → open=false, close event, returnValue="delete", focus back on "open"Styling: ::backdrop and :modal
dialog {
border: 0;
border-radius: 12px;
padding: 24px;
}
dialog::backdrop {
background: rgb(15 23 42 / 0.5);
}
/* Only when it was opened with showModal(), not show() */
dialog:modal {
box-shadow: 0 20px 60px rgb(0 0 0 / 0.3);
}:modal, which is what the original version of this post was about, matches only modal dialogs (and fullscreen elements). It's been supported since Chrome 105, Firefox 103 and Safari 15.6, so the "not supported in this browser" fallback from the original demo isn't needed any more.
Three things it still doesn't do
1. The page behind still scrolls
Scrolling the mouse wheel over the backdrop scrolled the page underneath in all three engines (scrollY went from 0 to 600 in Chromium and WebKit, and to 558 in Firefox). The page is inert, but it isn't frozen. One line of CSS fixes it, with no JavaScript:
html:has(dialog:modal) {
overflow: hidden;
}With that rule, the same scroll left scrollY at 0 everywhere.
2. returnValue isn't reset
returnValue keeps its value from the last time the dialog closed. In the test, the user clicked "Delete" once, then opened the dialog again and dismissed it by clicking the backdrop. The second close event still reported returnValue="delete":
click backdrop (closedby="any") → open=false cancel event | close event, returnValue="delete"Code that does if (dialog.returnValue === 'delete') deleteFile() would delete the file a second time. Clear it whenever the dialog opens:
const dialog = document.getElementById('confirm');
dialog.addEventListener('toggle', (event) => {
if (event.newState === 'open') dialog.returnValue = '';
});
dialog.addEventListener('close', () => {
if (dialog.returnValue === 'delete') deleteFile();
});Delete → deleted=1; reopen + Esc → returnValue="" deleted=1The file was deleted once, as it should be, in all three engines. The toggle event fires for dialogs in Chrome 132, Firefox 133 and Safari 26. If you support older browsers, set dialog.returnValue = '' yourself right before calling showModal().
3. Light dismiss isn't everywhere yet
closedby="any" closed the dialog on a backdrop click in all three test engines. But according to MDN's compatibility data it ships in Chrome 134 and Firefox 141, while Safari has it only in Technology Preview, and Playwright's WebKit is a newer build than shipping Safari. Until it lands, this fallback works everywhere. Backdrop clicks are dispatched to the dialog element itself, so a click whose coordinates are outside the dialog's box came from the backdrop:
dialog.addEventListener('click', (event) => {
const r = dialog.getBoundingClientRect();
const inside = event.clientX >= r.left && event.clientX <= r.right &&
event.clientY >= r.top && event.clientY <= r.bottom;
if (!inside) dialog.requestClose ? dialog.requestClose('') : dialog.close('');
});chromium: click inside → open=true; click backdrop → open=false; close events=1
firefox: click inside → open=true; click backdrop → open=false; close events=1
webkit: click inside → open=true; click backdrop → open=false; close events=1Keep closedby="any" on the element too. Browsers that support it handle the click themselves, and once the dialog is closed the fallback has nothing left to do.
Closing that can be vetoed: requestClose()
dialog.close() always closes. dialog.requestClose() behaves like the user pressing Esc: it fires cancel first, and a listener can call preventDefault() to keep the dialog open, for example when a form has unsaved changes:
dialog.addEventListener('cancel', (event) => {
if (form.dataset.dirty && !confirm('Discard your changes?')) event.preventDefault();
});requestClose(): vetoed → still open=true; second requestClose → open=false | cancel event | cancel event | close event, returnValue="x"requestClose() is available in Chrome 134, Firefox 139 and Safari 18.4. The fallback above checks for it before using it.
Sources & further reading
- MDN: <dialog> (including
closedby) - MDN: HTMLDialogElement.requestClose()
- MDN: Invoker Commands API (
commandfor/command) - MDN: :modal and ::backdrop
- HTML Standard: the dialog element
- MDN browser-compat-data for HTMLDialogElement