Open dialogs and popovers without JavaScript: HTML command and commandfor
HTML buttons can open and close dialogs and popovers on their own with command and commandfor, Baseline since December 2025. Built-in values, custom commands, fallbacks.
- A button with commandfor="id" and command="show-modal" opens a modal dialog with zero lines of JavaScript
- There are six built-in values: show-modal, close, request-close, show-popover, hide-popover, toggle-popover
- Values that start with -- are your own; the browser fires a command event on the target element
- As of October 2026 caniuse puts support at about 86% of users worldwide, so keep a fallback
One “open window” button used to need at least three lines of JavaScript: find the element, bind a click, call showModal(). HTML can now do it alone with two attributes: commandfor says what to control and command says what to do. Together they are called Invoker Commands, and every major browser ships them.
Facts checked against MDN, caniuse, the Chrome blog and web.dev as of October 2026: Chrome and Edge 135, Firefox 144, Safari 26.2.
Two attributes instead of a script
The old way:
<button id="open">Settings</button>
<dialog id="settings">...</dialog>
<script>
document.getElementById('open')
.addEventListener('click', () => document.getElementById('settings').showModal());
</script>
The new way:
<button type="button" commandfor="settings" command="show-modal">Settings</button>
<dialog id="settings">
<p>Account settings</p>
<button type="button" commandfor="settings" command="close">Close</button>
</dialog>
commandfor takes the id of the element to control, not a CSS selector. No script at all. The browser handles focus, the Esc key and the modal backdrop exactly as if you had called showModal() yourself.
The six built-in values
| Value | Works on | Equivalent |
|---|---|---|
show-modal | <dialog> | dialog.showModal() |
close | <dialog> | dialog.close(value) |
request-close | <dialog> | fires cancel first, closes unless prevented |
show-popover | element with popover | popovertargetaction="show" |
hide-popover | element with popover | popovertargetaction="hide" |
toggle-popover | element with popover | popovertargetaction="toggle" |
Issuing a command that matches the current state, such as show-modal on a dialog that is already open, does nothing and throws nothing.
close with value: the button’s value becomes the dialog’s returnValue, which gives you a confirm dialog without a form.
<dialog id="confirm-delete">
<p>Delete this file?</p>
<button type="button" commandfor="confirm-delete" command="close" value="cancel">Cancel</button>
<button type="button" commandfor="confirm-delete" command="close" value="delete">Delete</button>
</dialog>
<script>
const d = document.getElementById('confirm-delete');
d.addEventListener('close', () => {
if (d.returnValue === 'delete') deleteFile();
});
</script>
There is still JavaScript, but only your actual logic. The open/close plumbing is gone. In a real back-office screen with a dozen confirm and edit dialogs, that plumbing is most of the dialog code, and it is exactly the part that tends to forget focus handling or the Esc key on one screen out of twelve.
request-close differs from close because it fires a cancel event first. Call preventDefault() on cancel to keep the dialog open, for example when a form has unsaved changes.
dialog.addEventListener('cancel', (e) => {
if (formIsDirty()) e.preventDefault(); // keep it open
});
Popovers: a menu with no JavaScript
<button type="button" commandfor="menu" command="toggle-popover">Menu</button>
<div id="menu" popover>
<a href="/en/blog/">Articles</a>
<a href="/en/lab/">Lab notes</a>
</div>
The popover gets light dismiss (click outside or press Esc to close) and renders in the top layer. The Chrome team notes that built-in commands also set up the accessibility relationships for you (the equivalent of aria-expanded and aria-details), so you stop toggling aria-expanded by hand.
Reach for the built-in values first. The browser handles focus, Esc and accessibility, which is harder to get fully right yourself than it looks.
Your own commands: prefix with —
A value starting with two dashes is a custom command. The browser does nothing extra; it just fires a command event on the target element. The event carries command (the button’s value) and source (the button that was pressed).
<button type="button" commandfor="photo" command="--rotate-left">Rotate left</button>
<button type="button" commandfor="photo" command="--rotate-right">Rotate right</button>
<img id="photo" src="/img/cat.jpg" alt="A cat asleep on a keyboard">
<script>
const img = document.getElementById('photo');
let deg = 0;
img.addEventListener('command', (e) => {
if (e.command === '--rotate-left') deg -= 90;
if (e.command === '--rotate-right') deg += 90;
img.style.rotate = `${deg}deg`;
});
</script>
The listener lives on the thing being controlled, once. However many buttons there are, wherever they sit on the page, you never bind clicks one by one; a new button is just HTML.
The -- prefix exists because unprefixed names are reserved for future built-in commands. A custom name without dashes could collide with a real one later.
Support, counted in people
| Browser | First version |
|---|---|
| Chrome / Edge | 135 |
| Firefox | 144 |
| Safari (macOS / iOS) | 26.2 |
Safari 26.2 shipped in December 2025, which made the feature Baseline Newly available that month. As of October 2026 caniuse reports 86.32% of global users on a supporting browser.
A worked example: a site with 10,000 visitors a day whose audience matches the global average.
- Unsupported = 100% − 86.32% = 13.68%
- 10,000 × 0.1368 = 1,368 people a day who press the button and nothing happens
That is too many to ignore if the button is the only way to reach something important. Every audience is different, so check your own analytics, especially the share of Safari below 26.2, since Safari on iPhone updates with iOS.
A fallback for older browsers
Check for the commandForElement property. If it is missing, handle the built-in values you actually use with one delegated click listener.
if (!('commandForElement' in HTMLButtonElement.prototype)) {
document.addEventListener('click', (e) => {
const btn = e.target.closest('button[commandfor]');
if (!btn) return;
const el = document.getElementById(btn.getAttribute('commandfor'));
const cmd = btn.getAttribute('command');
if (!el) return;
if (cmd === 'show-modal') el.showModal();
else if (cmd === 'close') el.close(btn.value);
else if (cmd === 'toggle-popover') el.togglePopover();
else if (cmd === 'show-popover') el.showPopover();
else if (cmd === 'hide-popover') el.hidePopover();
});
}
This runs only where support is missing; modern browsers skip the whole block. When your analytics say the old share is small enough, delete it without touching the HTML. If you also use custom commands, you would have to construct a CommandEvent yourself, which is messier; a maintained polyfill or a plain click handler on that button is simpler.
Common mistakes
- A selector in commandfor.
commandfor="#settings"does not work; it must be the bare idsettings - Forgetting type=“button” inside a form. The default is submit, so the form posts or the page reloads
- A custom command without —.
command="rotate"fires no event; use--rotate - Listening on the button instead of the target. The
commandevent fires on the controlled element, not the button - Duplicate ids. The browser uses the first match and opens the wrong dialog silently. Watch for components rendered several times
- A
<div>styled as a button. commandfor only works on<button>, which is a good thing: you get keyboard and screen reader support for free
If HTML can do it, skip the JavaScript. What JavaScript remains should be real business logic.
A dialog opened this way still has to survive a phone screen. If you want AI to help with that, see how AI checks a mobile layout.
Sources
FAQ
Can it replace popovertarget?
Yes. command and commandfor do everything popovertarget does and control dialogs too. The Chrome team describes them as a replacement for the older attributes, but popovertarget still works, so there is no rush to rewrite existing code.
Will a command button inside a form submit the form?
Buttons inside a form default to type=submit. Put type="button" on every command button inside a form so you never have to guess.
What happens in a browser that does not support it?
The button does nothing and throws no error. Detect support with 'commandForElement' in HTMLButtonElement.prototype and attach your own click handler only when it is missing.
Commands and settings were tested on sample systems. Try them on a test machine before production.