12k
All articles

Controlling Web Components Without JavaScript

Use custom invoker commands to control web components with HTML only. See how command and commandfor wire buttons to a custom element without JavaScript.

OpenReplay Team
OpenReplay Team
Controlling Web Components Without JavaScript

Custom invoker commands let a custom element expose its actions declaratively: the component author writes a single command event listener in JavaScript, and everyone who uses the component wires buttons to it with the command and commandfor HTML attributes, with no script on the consumer’s side.

Shipping a custom element usually drags a README section along with it: the part explaining which methods to call, or which data-action attribute to sprinkle on buttons. The component works; the wiring is the friction.

This article starts where our guide to the Invoker Commands API stops. That piece covers the built-in commands for dialogs and popovers (show-modal, close, toggle-popover and friends) and the basics of the CommandEvent; none of that is repeated here. Invoker commands are Baseline Newly available since 12 December 2025, so there is no polyfill section either. Instead we build one distributable element, <code-viewer>, whose public API is its command set.

Key Takeaways

  • Custom command names must start with a double dash, such as --expand; the prefix is a reserved namespace, so a custom command can never collide with a built-in the browser adds later.
  • The command event fires directly on the element named by commandfor, does not bubble, and does not cross shadow boundaries, so the listener belongs on the component itself.
  • Inside the handler, event.command carries the command name and event.source is the button that fired it, which is where aria-pressed and aria-expanded belong.
  • A component cannot be targeted by commandfor from inside its own shadow tree because the host has no id there; the commandForElement property accepts a direct element reference instead.
  • The spec defines no state changes for custom commands, so the handler must maintain ARIA state itself.

The Author Writes JavaScript, the Consumer Writes HTML

The division of labor is the whole idea of custom invoker commands. You, the component author, write the command listener once, inside the element. Every consumer after that drives the component from markup:

<button command="--expand" commandfor="snippet">Expand</button>

No import beyond the component itself, no method names to memorize, no event delegation to hand-roll. The command set becomes the element’s public interface, documented the same way command="show-modal" is documented for <dialog>.

Why Do Methods and data-* Attributes Fall Short?

The two patterns component authors ship today both push work onto the consumer. An imperative method forces every consumer into JavaScript:

document.querySelector('#snippet').expand();
// plus a click listener on every button that should call it

A bespoke data-action attribute keeps the markup declarative but makes you reimplement dispatch: a delegated click listener, an attribute-parsing convention, and documentation for a vocabulary only your component understands. Neither approach gives you anything from the platform. With command/commandfor you inherit the semantics of a real button, keyboard activation included, and a wiring convention shared with every other command-driven element on the page.

How Do You Define a Custom Invoker Command?

Custom command values must begin with a double dash, and that prefix is reserved by definition. In the command attribute’s states, any value opening with -- is classed as a custom keyword, which leaves no room for a built-in to ever take that shape, so your commands cannot collide with anything the browser adds later. A value that is neither a built-in keyword nor ---prefixed is invalid and dispatches nothing.

Our <code-viewer> exposes three commands: --expand, --toggle-wrap, and --copy. That list, not a set of methods, is what its documentation advertises.

CommandWhat it doesARIA state to update on the button
--expandToggles the expanded attribute on the elementaria-expanded
--toggle-wrapToggles the wrap attribute on the elementaria-pressed
--copyWrites the element’s text content to the clipboardnone

Where Does the command Event Fire?

The command event fires on the target element named by commandfor, not on the button, and it does not bubble, so bubble-phase delegation on an ancestor will not see it. Attach the listener to the element receiving the command; the CommandEvent interface gives the handler two properties beyond the base event: event.command holds the command name, and event.source points back at the invoking button.

class CodeViewer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<pre><slot></slot></pre>`;
    this.addEventListener('command', this.#onCommand);
  }

  #onCommand = (event) => {
    switch (event.command) {
      case '--expand':
        // state change goes here
        break;
      case '--toggle-wrap':
        break;
      case '--copy':
        navigator.clipboard.writeText(this.textContent);
        break;
    }
  };
}

Dispatch on event.command with an explicit case list and no permissive default. Any ---prefixed value dispatches the event whether or not you handle it, and nothing throws when you don’t. Session replays of declaratively wired components show that failure mode as a dead button: the click lands, nothing changes on screen, and no console error fires, which is exactly what a mistyped command value or a listener on the wrong node looks like from the user’s side.

Shadow DOM: Targeting the Host with commandForElement

A component cannot be targeted by commandfor from inside its own shadow tree. The commandfor attribute only resolves ids that live in the button’s own tree, and the host, sitting out in the light DOM, has no id inside its own shadow root. The commandForElement property closes that gap: it accepts a direct element reference, across shadow roots, instead of an id. So an internal button can route through the same command handler:

connectedCallback() {
  const copyBtn = document.createElement('button');
  copyBtn.textContent = 'Copy';
  copyBtn.setAttribute('command', '--copy');
  copyBtn.commandForElement = this; // no id needed
  this.shadowRoot.append(copyBtn);
}

Two propagation facts matter here. The event is fired directly at the target with neither bubbles nor composed set, so it never crosses a shadow boundary and event.target is always the element that received it; no composedPath() gymnastics required. And event.source is retargeted against the listener’s tree: for that internal button, a listener on the host sees the host, not the button, so keep a direct reference to internal buttons if you need to update them.

State and ARIA Are Your Job

The spec defines no state changes for custom command values; the only browser behavior is dispatching the event. Nothing sets aria-pressed or aria-expanded for you, so update them on event.source in the same branch that mutates state:

case '--expand': {
  const expanded = this.toggleAttribute('expanded');
  event.source.setAttribute('aria-expanded', expanded);
  break;
}
case '--toggle-wrap': {
  const wrapped = this.toggleAttribute('wrap');
  event.source.setAttribute('aria-pressed', wrapped);
  break;
}

For the consumer’s light-DOM buttons this is safe: button and listener share a tree, so event.source is the actual button.

The Finished Element and the HTML That Drives It

Assembled, the component is one class with one listener:

class CodeViewer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<pre><slot></slot></pre>`;
    this.addEventListener('command', this.#onCommand);
  }

  #onCommand = (event) => {
    switch (event.command) {
      case '--expand': {
        const expanded = this.toggleAttribute('expanded');
        event.source.setAttribute('aria-expanded', expanded);
        break;
      }
      case '--toggle-wrap': {
        const wrapped = this.toggleAttribute('wrap');
        event.source.setAttribute('aria-pressed', wrapped);
        break;
      }
      case '--copy':
        navigator.clipboard.writeText(this.textContent);
        break;
    }
  };
}
customElements.define('code-viewer', CodeViewer);

And this is everything a consumer writes:

<code-viewer id="snippet">const answer = 42;</code-viewer>

<button command="--expand" commandfor="snippet" aria-expanded="false">Expand</button>
<button command="--toggle-wrap" commandfor="snippet" aria-pressed="false">Wrap</button>
<button command="--copy" commandfor="snippet">Copy</button>

Zero consumer JavaScript. The buttons can live anywhere in the document, in any order, added or removed at will.

Conclusion

A command set is a smaller, more durable public API than a method surface: it is inspectable in markup, keyboard-accessible by default, and namespaced so the platform can never break it. Pick one component you currently drive through methods or data-* conventions, move its actions behind ---prefixed commands, and let its next consumer wire it up without opening a script tag.

FAQs

What is the difference between commandfor and popovertarget?

The command and commandfor attributes replace and generalize popovertarget and popovertargetaction. The older pair only shows, hides, or toggles popovers, while command and commandfor also drive dialogs with values like show-modal and close, and dispatch custom double-dash commands to any element. The newer attributes support everything the older ones did, so new code should prefer command and commandfor.

Do command and commandfor work on elements other than button?

No. The HTML spec defines command and commandfor only on the button element, and the matching IDL properties, command and commandForElement, live on HTMLButtonElement. Links, inputs, and other elements cannot act as invokers. A custom element that wraps a native button also gets no invoker behavior for free: the internal native button must carry the attributes itself.

Does a button with command submit its parent form?

No, and that cuts both ways. A button carrying command or commandfor with no explicit type is not a submit button, so it will not submit the form. It will not run the command either: with a form owner present, the type is in the Auto state and activation returns before the command fires, so the button does nothing at all. Set type='button' on invoker buttons inside a form. The spec marks this restriction as a compatibility measure it plans to lift.

Can I handle command events with a delegated listener on an ancestor?

Only in the capture phase. The command event does not bubble, so a normal delegated listener on an ancestor never fires. Capture-phase listeners run on the way down to the target, so addEventListener('command', handler, { capture: true }) on a container catches command events dispatched to its descendants in the same tree. The event never crosses shadow boundaries, so delegation stops at shadow roots.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.