· 5 min read
HTML invoker commands: open dialogs without JavaScript
HTML invoker commands let a button open dialogs and toggle popovers with commandfor and command. How they work, custom commands, and the form gotcha.

HTML invoker commands let a plain <button> open a dialog, close it, or toggle a popover with two attributes and no JavaScript: commandfor names the target element by ID, and command says what to do to it. You can also define your own commands that start with -- and handle them with one event listener on the target. The API has been Baseline since late 2025, so it works in current Chrome, Edge, Firefox and Safari.
This fills a gap that has existed since <dialog> shipped. The browser has had a native modal for years, but you still needed a script just to call showModal() when someone clicked a button.
What are HTML invoker commands?
An invoker is a button that controls another element. You turn a button into one by adding commandfor, whose value is the ID of the element to control. The command attribute picks the action.
<button commandfor="settings" command="show-modal">Settings</button>
<dialog id="settings">
<p>Settings go here.</p>
<button commandfor="settings" command="close">Done</button>
</dialog>
That is the whole thing. Clicking the first button opens the dialog as a modal, with the backdrop, focus trapping and Escape handling that showModal() gives you. Clicking "Done" closes it. Keyboard activation with Enter or Space works too, because it is still a real button.
The built-in commands map directly to existing DOM methods:
command value | Target | Same as calling |
|---|---|---|
show-modal | <dialog> | dialog.showModal() |
close | <dialog> | dialog.close() |
request-close | <dialog> | dialog.requestClose() |
show-popover | element with popover | el.showPopover() |
hide-popover | element with popover | el.hidePopover() |
toggle-popover | element with popover | el.togglePopover() |
If you have used popovertarget, this is the general version of it. popovertarget only knew about popovers. commandfor is designed to grow as browsers add more commands.
Where do invoker commands work today?
Support landed in Chrome and Edge 135, Firefox 144 and Safari 26.2, which completed Baseline in December 2025. One value arrived later than the rest in Chromium: request-close needs Chrome 139 or newer.
Older browsers simply ignore the attributes. The button renders and does nothing. If you need to support them, keep a small script fallback, or load a polyfill and drop it once your analytics say it is safe.
How do close and request-close differ?
Both close a dialog, and both pass the button's value into dialog.returnValue, which is handy for confirm dialogs.
<dialog id="confirm">
<p>Discard your changes?</p>
<button commandfor="confirm" command="request-close" value="keep">Keep editing</button>
<button commandfor="confirm" command="close" value="discard">Discard</button>
</dialog>
The difference is the cancel event. close shuts the dialog immediately and fires only close. request-close behaves like the user pressing Escape: it fires cancel first, and if a listener calls preventDefault() on that event, the dialog stays open. Use request-close for any "dismiss" button where some other code might need a chance to say "wait, there are unsaved changes".
confirmDialog.addEventListener('cancel', (event) => {
if (form.dataset.dirty === 'true') event.preventDefault();
});
Whichever you choose, the result arrives in the dialog's close event, where you read returnValue. If you wrap dialogs in React, be aware that framework event props do not always map cleanly onto these native events; a portalled dialog not firing React's onClose is one example of that mismatch.
How do you write a custom command?
Any command value that starts with two dashes is a custom command. The browser does nothing with it except fire a command event on the target. The event is a CommandEvent with two useful properties: command, the string from the attribute, and source, the button that was pressed.
<img id="photo" src="/photo.jpg" alt="Product photo">
<button commandfor="photo" command="--rotate-left">Rotate left</button>
<button commandfor="photo" command="--rotate-right">Rotate right</button>
const photo = document.getElementById('photo');
let angle = 0;
photo.addEventListener('command', (event) => {
if (event.command === '--rotate-left') angle -= 90;
if (event.command === '--rotate-right') angle += 90;
photo.style.rotate = `${angle}deg`;
});
This is the part that changes how you structure code. Behavior lives on the element being controlled, and any number of buttons anywhere on the page can trigger it without each one needing its own click handler or a reference to the target. A value that is neither built in nor prefixed with -- is treated as unknown and does nothing, so typos fail quietly rather than throwing.
The command event also fires for the built-in commands, before the browser acts. It is cancelable, so calling preventDefault() in the listener stops the dialog or popover from opening. That gives you a clean hook for analytics or for blocking an action under some condition.
What are the gotchas?
A few behaviors are worth knowing before you rely on this. All of them follow the HTML spec and match current Chromium behavior.
- The event does not bubble. A
commandlistener ondocumentsees nothing. Attach the listener to the target element itself. - Inside a form, set
type="button". A command button inside a<form>with notypeattribute neither submits the form nor runs its command. Withtype="submit"it submits and still skips the command. Only an explicittype="button"gives you the command, so add it every time. - The target is found by ID in the same tree. Like
foron a label,commandfordoes not look inside other shadow roots. From script, thecommandForElementproperty reads or sets the target as an element reference. - Disabled buttons do nothing. That is usually what you want, but it means a disabled invoker will not fire the event either.
<form>
<!-- runs the command, never submits -->
<button type="button" commandfor="help" command="toggle-popover">Help</button>
</form>
Key takeaways
commandforpluscommandlets a button open, close or toggle dialogs and popovers with no JavaScript.- Use
request-closewhen other code should be able to cancel the dismissal, andclosewhen it should not. - Custom commands start with
--and arrive as acommandevent on the target, withevent.sourcepointing at the button. - The
commandevent is cancelable and does not bubble. - Inside forms, always add
type="button"to command buttons.
FAQ
Do invoker commands replace popovertarget?
They cover everything popovertarget does and more, since the same attributes also handle dialogs and custom actions. Existing popovertarget markup keeps working, so there is no need to rewrite it.
Can I use invoker commands without any JavaScript at all?
Yes, for the built-in dialog and popover commands. Custom -- commands need a listener on the target, because the browser has no idea what they should do.
Does it work with screen readers and keyboards?
The invoker is a normal <button>, so it keeps native focus, Enter and Space activation and its accessible name. Modal dialogs opened with show-modal get the same focus handling as calling showModal() from script.