The dialog element has been in every major browser since March 2022. It brings focus handling, the top layer, a backdrop and closing with Escape on its own. Even so, almost every dialog still comes with a bit of script, because until recently it could only be opened through showModal, and that meant a click listener on every button. Two attributes make that script unnecessary. command and commandfor on a button open and close a dialog or a popover in the markup alone. Chrome and Edge have supported them since version 135, Firefox since 144, Safari since 26.2, and MDN has listed them as Baseline since December 2025. closedby on the dialog decides whether a click outside closes it. Chrome and Edge have supported that since version 134 and Firefox since 141, Safari so far only in its preview release. This guide shows both attributes, the commands, the difference between close and request-close and the fallback for Safari.
- A button with command="show-modal" and commandfor set to the dialog's id opens it as a modal dialog without a line of script. command="close" closes it again.
- There are six built-in commands: show-modal, close and request-close for dialogs, show-popover, hide-popover and toggle-popover for popovers. Custom commands start with two hyphens and fire a command event on the target.
- closedby="any" also closes a dialog with a click outside. Without it, a modal dialog behaves as closerequest, meaning Escape, a non-modal one as none.
- Safari has supported command and commandfor since 26.2, but closedby not yet in a finished release. For the click outside, a few lines of script remain there, loaded only when the attribute is missing.
Why has a dialog needed JavaScript so far?
Because the dialog element can do everything a modal dialog needs, but could only be opened from a script. showModal puts it in the top layer, blocks the rest of the page, moves focus inside and closes it on Escape. You had to call showModal yourself though, and that meant a click listener on every button.
In a React project that quickly became more. A ref to the element, a function in the click handler, often state for open and closed that had to be kept in sync with the element, and a component holding button and dialog together because the button needs to know the ref. Many projects therefore reached straight for a library that rebuilds all of it a second time.
For popovers, popovertarget has offered a route without script for a while. Dialogs were left out, and that is exactly the gap command and commandfor close.
The dialog could do everything. It just could not open itself.
How do you open a dialog without JavaScript?
With a button carrying command="show-modal" and commandfor set to the dialog's id. A click on it does internally what showModal does, with focus, backdrop and top layer. A second button inside the dialog with command="close" and the same id closes it again.
The button does not have to sit next to the dialog. commandfor points through the id, so button and dialog can live in quite different places in the document, in different components too. What matters is that the id is unique and the target is an element that understands the command.
Chrome and Edge have supported the attributes since version 135, Firefox since 144 and Safari since 26.2. MDN has listed them as Baseline since December 2025, meaning newly available in all major browsers.
<button command="show-modal" commandfor="invite"> Invite people</button> <dialog id="invite" aria-labelledby="invite-title"> <h2 id="invite-title">Invite people</h2> <p>They will get an email with a link.</p> <button command="close" commandfor="invite">Done</button></dialog>
Which commands does command understand?
Six built-in ones, three for dialogs and three for popovers. On top of that come custom commands that start with two hyphens. A custom command does nothing by itself, it fires an event named command on the target, which states which command was meant.
That lets you drive your own components the same way as built-in elements. An image viewer can listen for --next and --previous, a video for --mute. The button stays a button with text and focus, and the script sits in exactly one place, on the target.
- show-modal Opens a dialog modally, like showModal: top layer, backdrop, rest of the page blocked.
- close Closes a dialog immediately, without asking.
- request-close Asks the dialog to close. A cancel event comes first, which a script can intercept.
- show-popover, hide-popover, toggle-popover The same for elements with the popover attribute, replacing popovertarget and popovertargetaction.
- --your-own-name Fires a command event on the target. Your script decides what happens next.
<button command="--next" commandfor="gallery">Next image</button> gallery.addEventListener('command', (event) => { if (event.command === '--next') showNext();});
How does a dialog close with a click outside?
With closedby="any" on the dialog. The attribute decides which routes can close the dialog and has three values. any allows everything, including a click on the backdrop. closerequest allows the platform's route, meaning Escape, plus closing from code, but no click outside. none allows only closing from code or a button.
Without it, a modal dialog behaves as closerequest and a non-modal one as none. That explains why a click on the backdrop never did anything: it was never meant to. For a dialog that only informs, any is usually right. For one where someone is filling in a form, rather not, because an accidental click outside throws everything away.
Chrome and Edge have supported closedby since version 134 and Firefox since 141. According to caniuse, Safari supports it so far only in Technology Preview. The attribute is part of the dialogs and popovers focus area of Interop 2026, together with the :open pseudo-class and popover="hint".
<dialog id="notice" closedby="any"> <p>Your changes were saved.</p> <button command="close" commandfor="notice">OK</button></dialog>
What is the difference between close and request-close?
close closes immediately, request-close asks first. With request-close the dialog fires a cancel event first, and only if nobody prevents it does closing follow with the close event. The same happens on Escape and on a click outside. request-close is the counterpart to the requestClose method in script.
That lets you handle the most common confirmation in one place: should the dialog really close while there are unsaved changes in it? A listener on cancel checks that and calls preventDefault when needed. Because Escape, click outside and the cancel button take the same route, there is one check instead of three.
For the button that confirms something, close stays right, because nothing should be asked there any more. For the one that cancels, request-close is the better choice.
<dialog id="edit" closedby="any"> <form method="dialog"> ... <button type="button" command="request-close" commandfor="edit">Cancel</button> <button>Save</button> </form></dialog> edit.addEventListener('cancel', (event) => { if (hasUnsavedChanges()) event.preventDefault();});
- The cancel button closes immediately, even with something still in the form.
- Escape and click outside need a check of their own that someone forgets.
- The confirmation lives in three places and eventually drifts apart.
- Cancel, Escape and click outside all fire the same cancel event.
- A single listener decides whether the dialog may close.
- The save button closes with close, without asking again.
What to do in browsers without support?
For command and commandfor usually nothing any more, for closedby a few lines. The commands have run in every major engine since Safari 26.2, so a fallback is only worth it when a noticeable share of visitors uses older versions. In that case you check whether buttons know the command property, and otherwise attach a click listener that calls showModal or close.
closedby is different, because Safari does not ship it yet. The fallback is small: a click whose target is the dialog itself rather than an element inside it came from the backdrop. You then call requestClose, so the same cancel route applies as in the other browsers. It matters that the dialog itself has no padding and the content sits in an element of its own, otherwise a click on the dialog's edge also counts as a click outside.
Both checks only run when the attribute is missing. In Chrome, Edge and Firefox the markup stays the only source, and once Safari ships closedby the fallback becomes ineffective on its own.
if (!('closedBy' in HTMLDialogElement.prototype)) { for (const dialog of document.querySelectorAll('dialog[closedby="any"]')) { dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.requestClose(); }); }}
First the markup that carries in three engines. Then the lines for the fourth.






