Skip to content
Interaction

Opening and closing dialogs without JavaScript

How a button opens a dialog with command and commandfor, which commands exist, how closedby handles closing with a click outside, where close and request-close differ and what Safari is still missing.

9 min readBy Leonie RoesmannUpdated: 07 November 2026
InteractionHTMLDialogAccessibility

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.

The short version
  • 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.
01

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.

02

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>
Opening and closing in the markup. No listener, no ref, no state.
03

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.

  1. show-modal Opens a dialog modally, like showModal: top layer, backdrop, rest of the page blocked.
  2. close Closes a dialog immediately, without asking.
  3. request-close Asks the dialog to close. A cancel event comes first, which a script can intercept.
  4. show-popover, hide-popover, toggle-popover The same for elements with the popover attribute, replacing popovertarget and popovertargetaction.
  5. --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();});
A custom command. The button stays in the markup, the script sits on the target.
04

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>
A notice dialog that can be closed by any route.
The close button stays mandatoryThe click outside is a shortcut, not a replacement. Someone using the keyboard or a screen reader never hits a backdrop, and in Safari the click outside does not work at all yet without an addition. So every dialog needs a visible button whose text says what happens, such as Close or Done, and not a lone X without a label.
05

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();});
Cancel, Escape and click outside run through the same check.
close everywhere
  • 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.
request-close to cancel
  • 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.
06

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();    });  }}
Only active when the browser does not know closedby.

First the markup that carries in three engines. Then the lines for the fourth.

Share
Written by
Leonie RoesmannDesign engineering

We build websites and web apps at Systra Studios in Münster and put the building blocks from that work here. What you read comes from real projects, not from a brochure.

Systra Studios: web design from Münster

One button opens, one button closes.
The menus for it are ready to take.

Navigation that lays itself over the page as a panel: from a small glass menu to full screen. Copy it and switch opening and closing to command and commandfor where the browser supports it.

  • One file per component, as source code in your own project.
  • Built with React, TypeScript and Tailwind CSS v4.
  • Every component respects reduced motion.
Browse navigation
FAQ

Frequently asked questions

For new projects yes, but popovertarget keeps working. When Chrome introduced the two attributes in version 135, the team explicitly described them as the successor to popovertarget and popovertargetaction. The difference is reach: popovertarget can only control popovers, commandfor any element that understands a command, so dialogs and your own components too. For a popover, instead of popovertarget="menu" and popovertargetaction="toggle" you simply write commandfor="menu" and command="toggle-popover". If you would rather not touch existing popovers, you do not have to: both routes exist side by side, and a project can mix them as long as one button uses only one of them.

Safari ignores the attribute and the dialog behaves as if it were not there. According to caniuse, Safari supports closedby so far only in Technology Preview, not in versions 26.6 to 27.2. A modal dialog there still closes with Escape, but not with a click on the backdrop. That is not a fault anyone notices as long as there is a visible close button, and every dialog should have one anyway. If you want the click outside in Safari too, add a listener that checks whether the click landed on the dialog itself rather than its content, and load it only when the browser does not know closedby. closedby is part of the dialogs and popovers focus area of Interop 2026, in which Apple takes part.

Yes, as ordinary attributes in JSX. React passes unknown attributes with string values through to the DOM, and because HTML attributes are case-insensitive, commandfor and commandFor arrive the same. The benefit in React is bigger than elsewhere: a dialog that only opened through showModal needed a ref to the element and a function in the click handler, often with state for open or closed on top. With commandfor all three go away, and the button can even live in a different component from the dialog, as long as both sit in the same document and the id is unique. Whether TypeScript accepts the attributes without complaint depends on the version of the React types; if they are missing, you add them in a declaration of your own.

When the dialog needs to do something beyond opening and closing. There are three main cases. First, submitting a form inside the dialog when the page should not reload; a form with method="dialog" closes the dialog by itself, but saving the data takes a script. Second, asking for confirmation before closing, for instance when there are unsaved changes; for that you listen for the cancel event that request-close fires before closing and prevent it when needed. Third, custom commands with two hyphens, which do nothing on their own and only fire a command event on the target. In all three cases opening stays in the markup, and the script is limited to what is genuinely your own.

Didn't find your question? We're happy to help.

Get in touch