Skip to main content
Back to Blog a retro-style control panel of buttons, each labeled with a clear intent — open, close, toggle

Buttons That Know What They Want

Tom Hermans

Table Of Contents

I’ve been playing with the Invoker Commands API.

Not because I had a specific project that needed it.
Just because I saw the spec, read two paragraphs, and immediately opened a new Codepen.

That’s usually a good sign.

What’s a button actually for?

Buttons do things.
That’s their whole job.

But for a long time, what they did required you to write it in a completely separate place.

You’d have a <button> in your HTML.
Then somewhere in a <script> tag — or a separate JS file — you’d wire it up:

document.querySelector('#open-btn').addEventListener('click', () => {
  document.querySelector('#my-dialog').showModal();
});

It works. It’s fine. We’ve all done it a thousand times.
But there’s a disconnect. The button and its intention live in different places.

The HTML tells you what it is.
The JavaScript tells you what it does.
The relationship between the two? Implicit. Fragile. One refactor away from breaking silently.

Enter: command and commandfor

The Invoker Commands API closes that gap.

Two new attributes on <button>:

  • commandfor — points to the element you want to act on (by ID)
  • command — declares what action to take
<button type="button" commandfor="my-dialog" command="show-modal">
  Open Dialog
</button>

<dialog id="my-dialog">
  Hello there.
  <button type="button" commandfor="my-dialog" command="close">Close</button>
</dialog>

No JavaScript. No event listener. No querySelector.

The button says what it does, right there in the markup. The browser reads the command, recognises the target, and handles the rest — including all the accessibility plumbing that you’d previously have to rebuild by hand.

That’s not a small thing.

The commands that exist right now

The spec ships with a set of built-in commands. I worked through all of them in my Codepen, and here’s what’s available:

For popovers:

CommandWhat it does
toggle-popoverShows if hidden, hides if shown
show-popoverShows if hidden, does nothing if already open
hide-popoverHides if shown, does nothing if already hidden

For dialogs:

CommandWhat it does
show-modalOpens the <dialog> as a modal
closeCloses the dialog, sets returnValue to the button’s value attribute
request-closeRequests a close — respects the cancel event if you’ve prevented it

A few things worth noting:

show-popover and show-modal are idempotent. Call them on something that’s already open and nothing happens. No errors, no double-opening. The browser just shrugs.

close is interesting because it actually sets dialog.returnValue to the button’s value attribute. So you can have multiple close buttons with different values — “confirmed”, “cancelled”, “later” — and read the outcome programmatically if you need to:

<button type="button" commandfor="my-dialog" command="close" value="confirmed">
  Confirm
</button>
<button type="button" commandfor="my-dialog" command="close" value="cancelled">
  Cancel
</button>

request-close is the polite version. If you’ve added a cancel event listener on the dialog and called preventDefault() — maybe you want to warn users about unsaved changes — request-close will respect that. close will not.

It goes beyond popovers

popovertarget already gave us declarative popover control. That was great, but it only solved one narrow case.

The invoker approach is more general. You’re not targeting a popover behaviour — you’re targeting an element and declaring an intent. The element decides how to respond.

And there’s a custom command escape hatch:

<button type="button" commandfor="my-thing" command="--do-something">
  Trigger custom behaviour
</button>

<script>
  document.getElementById('my-thing').addEventListener('command', (e) => {
    if (e.command === '--do-something') {
      // whatever you need
    }
  });
</script>

Custom commands must start with --, the same convention as CSS custom properties. The browser guarantees it’ll never use that namespace for built-in commands, so you’re safe to use it forever.

This is a good design. It gives you a clean hook for custom JS behaviour while keeping the wiring declarative and colocated with your markup.

A note on <button type="button">

Worth being explicit: command and commandfor only work on <button> elements.

Not <div>, not <a>, not a custom element. A <button>.

And if that button is inside a <form>, it needs type="button" explicitly — otherwise the browser treats it as a submit button and ignores the invoker attributes. The spec calls this an “author error”, which is a polite way of saying it’ll fail silently and you’ll wonder why for twenty minutes.

So: always type="button" when using invoker commands.

Browser support — the honest part

Here’s the good news: this one has landed.

As of December 2025, the Invoker Commands API is considered Baseline — meaning it’s shipped across all major browsers. Chrome and Edge from version 135, Firefox from version 144, and Safari completing the rollout with version 26.2.

So the “you need JS as a fallback” caveat is shrinking fast. If you’re targeting current browsers, you can use this today.

The one thing worth keeping in mind: “latest browsers” is doing some work in that sentence. Users on older devices or browsers that haven’t auto-updated are still out in the cold. For anything genuinely production-critical, a lightweight progressive enhancement layer still makes sense:

if (!HTMLButtonElement.prototype.hasOwnProperty('command')) {
  // add your JS-based open/close handlers as fallback
}

And if you need to ship it right now regardless of support, Keith Cirkel — one of the spec authors — maintains an MIT-licensed polyfill (opens in a new tab). Add it, write your declarative HTML, delete the polyfill later when you’re comfortable. Clean exit strategy.

But honestly? For personal projects, demos, and anything aimed at a reasonably modern audience: just use it. It’s here.

Play with the demo

You may also want to open the console in Codepen or the devtools to see what’s happening there.

View on Codepen (opens in a new tab)

The Codepen above includes the invoker-commands polyfill by Keith Cirkel, so it’ll work regardless of your browser. If you want to test native support, comment out the polyfill import and reload — if your browser is current, nothing should break.

Why this matters

I keep coming back to one thing:

This is HTML describing intent.

Not just structure. Not just content. Actual, declared, meaningful intent — in the same place as the element that holds it.

That’s a shift. A small one, maybe. But the kind of small shift that quietly changes how you think about building things.

The less JavaScript you need for basic interactivity, the more resilient your pages become. The more colocated your intent is with your markup, the easier your code is to read, debug, and maintain.

And honestly?
It’s just satisfying.

A button that knows what it wants.
Imagine that.


Spec: open-ui.org/components/invokers.explainer (opens in a new tab)
MDN: Invoker Commands API (opens in a new tab)

Back to Blog

Let's Talk

Tom avatar

Get in touch

I work at the intersection of design and code.
Interested? Hit me up.
tomhermans@gmail.com

Copyright © 2026 Tom Hermans. Made by Tom Hermans .

All rights reserved 2026 inc Tom Hermans