← Read

Design · · 6 min read

The dialog you can open, close and style without JavaScript

A button attribute opens a modal and a pseudo-class styles it, with no script. What's Baseline, what still needs JavaScript, and the attribute Safari hasn't shipped.

A modal dialog with a trigger, a close button and open-state styling, with no JavaScript at all:

<button command="show-modal" commandfor="signup">Sign up</button>

<dialog id="signup">
  <h2>Create an account</h2>
  <form method="dialog">
    <input type="email" name="email" required>
    <button value="accept">Continue</button>
  </form>
  <button command="close" commandfor="signup">Cancel</button>
</dialog>
dialog::backdrop { background: rgb(0 0 0 / 0.5); }
#signup:open { animation: pop 150ms ease-out; }

The command and commandfor attributes became Baseline newly available on 12 December 2025, and the :open pseudo-class on 11 May 2026. One piece of the picture — light dismiss via the closedby attribute — is not cross-browser yet, and that's the part to read carefully before you delete anything.

Opening and closing with buttons

commandfor takes the id of an element in the same tree; command says what to do to it. The HTML Standard defines six keywords, plus a custom form:

command valueEffect
show-modalOpens the targeted <dialog> as modal
closeCloses the targeted <dialog>
request-closeRequests to close it — fires cancel first, which can be prevented
show-popoverShows the targeted popover
hide-popoverHides it
toggle-popoverShows or hides it
a keyword starting with --Dispatches a command event on the target and nothing else

Two absences are worth noticing. There is no keyword for opening a non-modal dialog: show-modal is the only opening command, so a declaratively-opened dialog is always modal. For a non-modal overlay with no script, use the popover attribute instead — show-popover, hide-popover and toggle-popover are there for exactly that.

And an unrecognised value is not an error you'll see. The attribute's missing and invalid value defaults are both an "Unknown" state, so a typo like command="showmodal" silently does nothing.

Why request-close exists

close closes the dialog immediately. request-close behaves as if the user had asked to close it: a cancel event fires first, and if a listener calls preventDefault(), the dialog stays open. That's the hook for "you have unsaved changes" — the only part of that flow needing JavaScript is the condition itself.

The matching DOM method, requestClose(), ignores the closedby attribute entirely: even with closedby="none", calling it closes the dialog.

Custom commands

A command value starting with two hyphens does nothing except dispatch a command event on the target element, where event.source is the button that fired it. It's a declarative way to wire a button to a component without an id-based event listener of your own — useful in component libraries, and available in the same browser versions as the built-in keywords.

Styling the open state

:open matches any element that has open and closed states and is currently open — the spec names <details>, <select>, <dialog> and <input>.

details:open summary { border-bottom-color: transparent; }
select:open         { outline: 2px solid Highlight; }   /* while the picker shows */
dialog:open         { translate: 0 0; }

For <details> and <dialog> you could already select the open attribute with [open]. The reason :open matters is the elements with no attribute to select: a <select> showing its picker has no markup change at all, so before :open there was no way to style it from CSS.

Three details to keep straight:

  • :open is a semantic state, not a visibility state. The spec is explicit that an element inside a display: none subtree, or with visibility: collapse, still matches :open if it is semantically open.
  • :popover-open is a different thing. An element can be open in its own right and be a showing popover; the two pseudo-classes are deliberately separate. Use :popover-open for popovers.
  • There is no :closed. The spec notes one might be added "once the full set of things that support :open is known". Until then, negate: :not(:open).

Support for <input> pickers is the shakiest corner — the web-platform test for it is marked optional — so treat input:open as a nice-to-have rather than a mechanism.

Because :open is a plain selector, :has() gives you page-level reactions without a class or a script:

/* fade a sticky header while any dialog is open */
body:has(dialog:open) .site-header { opacity: 0.4; }

The gap: light dismiss

A modal opened with show-modal already closes on Escape, because the dialog's close-behaviour defaults to treating a close request as a close when it was shown modally. What it doesn't do by default is close when you click outside it.

That's what closedby is for:

closedbyCloses on
anyEscape and a click outside the dialog
closerequestEscape only
noneNeither — only close(), requestClose() or a close command
omittedThe Auto state: behaves as closerequest when opened with showModal(), otherwise as none
html.spec.whatwg.org ↗The dialog closedby attribute in the HTML StandardThe normative definition, including the Auto state. Source: WHATWG

And here is the honest support position at the time of writing: closedby shipped in Chrome and Edge 134 (March 2025) and Firefox 141 (July 2025), is in Safari Technology Preview, and is not available in Safari on iOS. Since it's an unknown attribute in Safari, it's simply ignored — a closedby="any" dialog still opens and still closes on Escape, it just won't light-dismiss.

That degradation is mild enough to ship as progressive enhancement for most dialogs. If click-outside dismissal is load-bearing for your interface, that's the one place you still need a few lines of script, or a popover instead — popover="auto" has light dismiss everywhere it's supported.

What still needs JavaScript

Declarative dialogs cover opening, closing and styling. They don't cover:

  • Reading the result. <form method="dialog"> closes the dialog on submit and sets returnValue from the submitter's value, with no script — but reading dialog.returnValue afterwards is JavaScript.
  • Conditional closing. The cancel listener behind request-close.
  • Content loaded on demand. Nothing here fetches anything.
  • Anything the user's data decides. Which dialog, which copy, which state.

What you get back is the fiddly part: the top layer, the backdrop, focus moving into the dialog and returning on close, and Escape handling all come from showModal(), which the show-modal command calls for you.

Browser support

FeatureChrome, EdgeFirefoxSafariBaseline
command, commandfor, CommandEvent135 (April 2025)144 (14 October 2025)26.2 (12 December 2025)Newly available, 12 December 2025
:open133 (February 2025)136 (4 March 2025)26.5 (11 May 2026)Newly available, 11 May 2026
<dialog closedby>134 (March 2025)141 (22 July 2025)Technology Preview; no iOSNot Baseline
<dialog> itself37, Edge 799815.4Widely available since 14 September 2024

Support data from @mdn/browser-compat-data 8.1.1 and web-features 3.38.0, correct at the time of writing. Note that Chrome and Edge recognised :open from version 114 without it having any effect — a partial implementation, so @supports selector(:open) is not a reliable test in that range.

Dialogs and popovers are a focus area of Interop 2026, which covers closedby, :open and popover="hint" specifically.

FAQ

Can I open a non-modal dialog without JavaScript?

No. show-modal is the only command that opens a dialog, and it opens it modally. For a non-modal overlay, use the popover attribute with show-popover or toggle-popover.

Does command work on any element?

The attributes are defined on <button>. A button that is inside a <select> element doesn't act as an invoker.

What happens in a browser that doesn't support command?

The button does nothing. If the dialog matters, render the page so that the content is still reachable — or add a click handler as a fallback and let the declarative path take over where it works.

Is :open the same as the open attribute?

Not quite. [open] selects markup, which only exists on <details> and <dialog>. :open selects the semantic state, which also covers a <select> whose picker is showing.

Sources

More to read