Published: March 7, 2025
Buttons are essential to making dynamic web applications. Buttons open menus, toggle actions, and submit forms. They provide the foundation of interactivity on the web. Making buttons simple and accessible can lead to some surprising challenges. Developers building micro-frontends or component systems can encounter solutions that become more complex than necessary. While frameworks help, the web can do more here.
Chrome 135 introduces new capabilities for providing declarative behaviour with
the new command
and commandfor
attributes, enhancing and replacing the
popovertargetaction
and popovertarget
attributes. These new attributes can
be added to buttons, letting the browser address some core issues around
simplicity and accessibility, and provide built-in common functionality.
Traditional patterns
Building button behaviours without a framework can pose some interesting
challenges as production code evolves. While HTML offers onclick
handlers to
buttons, these are often disallowed outside of demos or tutorials due to Content
Security Policy (CSP) rules. While these events are dispatched on button
elements, buttons are usually placed on a page to control other elements
requiring code to control two elements at once. You also need to ensure this
interaction is accessible to users of assistive technology. This often leads to
code looking a bit like this:
<div class="menu-wrapper">
<button class="menu-opener" aria-expanded="false">
Open Menu
</button>
<div popover class="menu-content">
<!-- ... -->
</div>
</div>
<script type="module">
document.addEventListener('click', e => {
const button = e.target;
if (button.matches('.menu-opener')) {
const menu = button
.closest('.menu-wrapper')
.querySelector('.menu-content');
if (menu) {
button.setAttribute('aria-expanded', 'true');
menu.showPopover();
menu.addEventListener('toggle', e => {
// reset back to aria-expanded=false on close
if (e.newState == 'closed') {
button.setAttribute('aria-expanded', 'false');
}
}, {once: true})
}
}
});
</script>
This approach can be a little brittle, and frameworks aim to improve ergonomics. A common pattern with a framework like React might involve mapping the click to a state change:
function MyMenu({ children }) {
const [isOpen, setIsOpen] = useState(false);
const open = useCallback(() => setIsOpen(true), []);
const handleToggle = useCallback((e) => {
// popovers have light dismiss which influences our state
setIsOpen(e.newState === 'open')
}, []);
const popoverRef = useRef(null);
useEffect(() => {
if (popoverRef.current) {
if (isOpen) {
popoverRef.current.showPopover();
} else {
popoverRef.current.hidePopover();
}
}
}, [popoverRef, isOpen]);
return (
<>
<button onClick={open} aria-expanded={isOpen}>
Open Menu
</button>
<div popover onToggle={handleToggle} ref={popoverRef}>
{children}
</div>
</>
);
}
Many other frameworks also aim to provide similar ergonomics, for example this might be written in AlpineJS as:
<div x-data="{open: false}">
<button @click="open = !open; $refs.popover.showPopover()" :aria-expanded="open">
Open Menu
</button>
<div popover x-ref="popover" @toggle="open = $event.newState === 'open'">
<!-- ... -->
</div>
</div>
While writing this in Svelte might look something like:
<script>
let popover;
let open = false;
function togglePopover() {
open ? popover.hidePopover() : popover.showPopover();
open = !open;
}
</script>
<button on:click={togglePopover} aria-expanded={open}>
Open Menu
</button>
<div bind:this={popover} popover>
<!-- ... -->
</div>
Some design systems or libraries might go a step further, by providing wrappers around button elements that encapsulate the state changes. This abstracts state changes behind a trigger component, trading a little flexibility for improved ergonomics:
import {MenuTrigger, MenuContent} from 'my-design-system';
function MyMenu({children}) {
return (
<MenuTrigger>
<button>Open Menu</button>
</MenuTrigger>
<MenuContent>{children}</MenuContent>
);
}
The command and commandfor pattern
With the command
and commandfor
attributes, buttons can now perform actions
on other elements declaratively, bringing the ergonomics of a framework without
sacrificing flexibility. The commandfor
button takes an ID—similar to the
for
attribute—while command
accepts built-in values, enabling a more
portable and intuitive approach.
Example: An open menu button with command and commandfor
The following HTML sets up declarative relationships between the button and the
menu which lets the browser handle the logic and accessibility for you. There's
no need to manage aria-expanded
or add any additional JavaScript.
<button commandfor="my-menu" command="show-popover">
Open Menu
</button>
<div popover id="my-menu">
<!-- ... -->
</div>
Comparing command
and commandfor
with popovertargetaction
and popovertarget
If you've used popover
before, you might be familiar with the popovertarget
and popovertargetaction
attributes. These work similarly to commandfor
and
command
respectively—except they're specific to popovers. The command
and
commandfor
attributes completely replace these older attributes. The new
attributes support everything the older attributes did, as well as adding new
capabilities.
Built-in commands
The command
attribute has a set of built-in behaviours which map to various
APIs for interactive elements:
show-popover
: Maps toel.showPopover()
.hide-popover
: Maps toel.hidePopover()
.toggle-popover
: Maps toel.togglePopover()
.show-modal
: Maps todialogEl.showModal()
.close
: Maps todialogEl.close()
.
These commands map to their JavaScript counterparts, while also streamlining
accessibility (such as providing the aria-details
and aria-expanded
equivalent relations), focus management, and more.
Example: A confirmation dialog with command
and commandfor
<button commandfor="confirm-dialog" command="show-modal">
Delete Record
</button>
<dialog id="confirm-dialog">
<header>
<h1>Delete Record?</h1>
<button commandfor="confirm-dialog" command="close" aria-label="Close" value="close">
<img role="none" src="/close-icon.svg">
</button>
</header>
<p>Are you sure? This action cannot be undone</p>
<footer>
<button commandfor="confirm-dialog" command="close" value="cancel">
Cancel
</button>
<button commandfor="confirm-dialog" command="close" value="delete">
Delete
</button>
</footer>
</dialog>
Clicking the Delete Record button will open the dialog as a modal, while
clicking the Close, Cancel, or Delete buttons will close the dialog while
also dispatching a "close"
event on the dialog, which has a returnValue
property matching the button's value.
This reduces the need for JavaScript beyond a single event
listener on the dialog to determine what to do next:
dialog.addEventListener("close", (event) => {
if (event.target.returnValue == "cancel") {
console.log("cancel was clicked");
} else if (event.target.returnValue == "close") {
console.log("close was clicked");
} else if (event.target.returnValue == "delete") {
console.log("delete was clicked");
}
});
Custom commands
In addition to the built-in commands, you can define custom commands using a
--
prefix. Custom commands will dispatch a "command"
event on the target
element (just like the built-in commands), but otherwise will never perform any
additional logic like the built-in values do. This gives flexibility for
building components that can react to buttons in various ways without having to
provide wrapper components, traverse the DOM for the target element, or mapping
button clicks to state changes. This lets you provide an API within HTML for
your components:
<button commandfor="the-image" command="--rotate-landscape">
Landscape
</button>
<button commandfor="the-image" command="--rotate-portrait">
Portrait
</button>
<img id="the-image" src="photo.jpg">
<script type="module">
const image = document.getElementById("the-image");
image.addEventListener("command", (event) => {
if ( event.command == "--rotate-landscape" ) {
image.style.rotate = "-90deg"
} else if ( event.command == "--rotate-portrait" ) {
image.style.rotate = "0deg"
}
});
</script>
Commands in the ShadowDOM
Given the commandfor
attribute takes an ID, there are restrictions around
crossing the shadow DOM. In these cases you can use the JavaScript API to set
the .commandForElement
property which can set any element, across shadow
roots:
<my-element>
<template shadowrootmode=open>
<button command="show-popover">Show popover</button>
<slot></slot>
</template>
<div popover><!-- ... --></div>
</my-element>
<script>
customElements.define("my-element", class extends HTMLElement {
connectedCallback() {
const popover = this.querySelector('[popover]');
// The commandForElement can set cross-shadow root elements.
this.shadowRoot.querySelector('button').commandForElement = popover;
}
});
</script>
Future proposals may provide a declarative way to share references across shadow boundaries, such as the Reference Target Proposal.
What's next?
We'll be continuing to explore possibilities for new built-in commands, to cover common functionality that websites use. Proposed ideas are covered in the Open UI Proposal. Some of the ideas already explored:
- Opening and closing
<details>
elements. - A
"show-picker"
command for<input>
and<select>
elements, mapping toshowPicker()
. - Playback commands for
<video>
and<audio>
elements. - Copying text content from elements.
We welcome community input—if you have suggestions don't hesitate to file an issue on the Open UI Issue Tracker.
Learn more
Find more information about command
and commandfor
in
the specification
and on
MDN.