<bmx-menu>
A menu of commands: the one that drops from a button, the one that cascades into submenus, and the one that appears where the pointer was right-clicked. All three are the same component, because they are the same widget with a different way of being opened - and a library that ships them as three grows three subtly different keyboards.
10 properties · 2 events · 3 methods · 4 parts
Example
Or written as markup, with no script at all:
Show markup
<div class="row">
<bmx-button id="ex-menu-file" haspopup="menu">File</bmx-button>
<bmx-menu id="ex-menu-1" trigger="#ex-menu-file" label="File"></bmx-menu>
<span class="note">A menu, a button, and one attribute joining them. Arrow keys move, letters search, Escape closes.</span>
</div>
<div class="row">
<div
id="ex-menu-canvas"
style="
display: grid;
place-items: center;
min-block-size: 7rem;
border: 1px dashed var(--bmx-border);
border-radius: var(--bmx-radius-lg);
color: var(--bmx-text-muted);
"
>
Right-click anywhere in here
</div>
<bmx-menu id="ex-menu-context" context="#ex-menu-canvas" label="Canvas actions"></bmx-menu>
</div>
<div class="row">
<bmx-button id="ex-menu-view" haspopup="menu" variant="outline" tone="neutral">View</bmx-button>
<bmx-menu id="ex-menu-checks" trigger="#ex-menu-view" label="View"></bmx-menu>
<span class="note" id="ex-menu-state">Sorted by name. Hidden files off.</span>
</div>
<div class="row">
<p style="margin: 0">Or written as markup, with no script at all:</p>
</div>
<div class="row">
<bmx-button id="ex-menu-edit" haspopup="menu" variant="soft" tone="neutral">Edit</bmx-button>
<bmx-menu trigger="#ex-menu-edit" label="Edit">
<bmx-menu-item value="undo" label="Undo" shortcut="⌘Z"></bmx-menu-item>
<bmx-menu-item value="redo" label="Redo" shortcut="⇧⌘Z" disabled></bmx-menu-item>
<bmx-menu-item separator></bmx-menu-item>
<bmx-menu-item value="cut" label="Cut" shortcut="⌘X"></bmx-menu-item>
<bmx-menu-item value="copy" label="Copy" shortcut="⌘C"></bmx-menu-item>
<bmx-menu-item label="Paste special">
<bmx-menu-item value="paste-text" label="Text only"></bmx-menu-item>
<bmx-menu-item value="paste-values" label="Values only"></bmx-menu-item>
</bmx-menu-item>
</bmx-menu>
</div>
<script type="module">
await customElements.whenDefined('bmx-menu');
// `items` is an array, so it is a property rather than an attribute. A menu
// written by hand can use `<bmx-menu-item>` children instead - see the last
// example above - but anything built from data belongs here.
document.getElementById('ex-menu-1').items = [
{ id: 'new', label: 'New', shortcut: '⌘N' },
{
id: 'recent',
label: 'Open recent',
items: [
{ id: 'accounts', label: 'Accounts.xlsx', description: 'Edited yesterday' },
{ id: 'budget', label: 'Budget.xlsx', description: 'Edited last week' },
{ separator: true },
{ id: 'clear', label: 'Clear the list', danger: true },
],
},
{ separator: true },
{ id: 'print', label: 'Print', shortcut: '⌘P', disabled: true },
{ id: 'close', label: 'Close without saving', danger: true },
];
document.getElementById('ex-menu-context').items = [
{ id: 'zoom-in', label: 'Zoom in', shortcut: '⌘+' },
{ id: 'zoom-out', label: 'Zoom out', shortcut: '⌘−' },
{ separator: true },
{ id: 'reset', label: 'Reset the view' },
];
/*
* Checkable items report the choice; they do not change themselves. The items
* array belongs to the application - one rendered in two places must not
* change under the other one - so `applyCheck` is exported for you to apply
* the choice, with the radio rule already right.
*/
const view = document.getElementById('ex-menu-checks');
const readout = document.getElementById('ex-menu-state');
view.items = [
{ id: 'hidden', label: 'Show hidden files', kind: 'checkbox', checked: false },
{ separator: true },
{ id: 'name', label: 'Sort by name', kind: 'radio', group: 'sort', checked: true },
{ id: 'date', label: 'Sort by date', kind: 'radio', group: 'sort' },
{ id: 'size', label: 'Sort by size', kind: 'radio', group: 'sort' },
];
view.addEventListener('bmxSelect', event => {
const chosen = event.detail.index;
const items = view.items.map(item => ({ ...item }));
const target = items[chosen];
if (target.kind === 'radio') {
items.forEach((item, index) => {
if (item.kind === 'radio' && item.group === target.group) {
item.checked = index === chosen;
}
});
} else if (target.kind === 'checkbox') {
target.checked = !target.checked;
}
view.items = items;
const sort = items.find(item => item.group === 'sort' && item.checked);
readout.textContent = `Sorted by ${sort.label.replace('Sort by ', '')}. Hidden files ${items[0].checked ? 'on' : 'off'}.`;
});
</script>
HOW IT IS OPENED
Three ways, in the order they cost the author effort:
triggernames an element. The menu wires that element'saria-haspopup,aria-expandedandaria-controls, opens on a press, opens at the bottom of the list on ArrowUp, and closes again on a second press. This is the whole widget in one attribute, and it needs no script.contextnames an element. A right-click anywhere inside it opens the menu at the pointer instead of against a box.openis set, oropenMenu()is called. The composing component decides, which is whatbmx-split-buttondoes - it already owns a trigger and does not want a second opinion about it.
WHY EACH LEVEL IS ITS OWN SURFACE
A cascading menu could be drawn as nested boxes inside one element. It should
not be: the root has overflow-y: auto so a long menu can scroll, and an
overflow scroller clips its own descendants - so the first submenu on a long
menu would be cut off at the parent's edge. Each level is therefore its own
element in the top layer, positioned against the item that opened it, which
also means each gets the flipping and viewport clamping that
BmxOverlaySurface already does.
The cost is that "outside" now means outside all of them, which is why the
overlay engine grew a related option for this component.
WHAT MAKES IT USABLE RATHER THAN MERELY CORRECT
The safe triangle, in src/core/menu.ts. A submenu opens beside its parent
item, so reaching it means moving the pointer diagonally across the items
below - and a menu that closes on mouseleave snaps shut halfway through
every attempt. Here the pointer's direction of travel is tested against the
open submenu's near edge, so a pointer heading for the submenu keeps it, and
a pointer heading anywhere else loses it at once. It is the difference
between a cascading menu people use and one they learn to avoid.
ACCESSIBILITY
role="menu" with menuitem, menuitemcheckbox and menuitemradio
children and a roving tabindex; focus moves into the menu on open and back to
whatever opened it on close; a parent item carries aria-haspopup="menu" and
aria-expanded; typeahead follows the platform, including Space joining a
search already in progress rather than firing the item under it. RTL mirrors
the arrows and the side the submenus open on.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
anchor |
anchor |
string | HTMLElement |
— | What the root surface is placed against. Defaults to the trigger. Separate from trigger because the two answer different questions. A bmx-split-button wires the ARIA to its chevron half and anchors the menu to both halves, so that bottom-start lines up with the primary action's leading edge rather than hanging a wide menu off a narrow button. |
context |
context |
string | HTMLElement |
— | Right-clicking inside this element opens the menu at the pointer. |
items |
property only | BmxMenuEntry[] |
[] |
The menu's entries. An array, or the JSON spelling of one. Anything with a wrapper - React, Angular, Vue - assigns the array; markup that can only write attributes (Razor, Blazor's markup, a server-side template, an htmx swap) writes the JSON, and it is read once into the list it describes. Mutable for that reason alone: everything below reads a list. |
label |
label |
string |
'Menu' |
The menu's accessible name. |
menuId |
menu-id |
string |
— | The id given to the root surface. Only needed by a component that owns its own trigger and has to point that trigger's aria-controls at a surface it does not render itself. Left alone, the menu generates one. |
open |
open |
boolean |
false |
Whether the menu is showing. Mutable, so the component can close itself when something is chosen. A consumer driving it may treat it as controlled and watch bmxOpenChange. |
placement |
placement |
BmxPlacement |
'bottom-start' |
Which side the root opens on when there is room. |
returnFocus |
return-focus |
boolean |
true |
Whether closing returns focus to whatever opened the menu. |
returnFocusTo |
return-focus-to |
string | HTMLElement |
— | Where focus goes when the menu closes, as a CSS selector or an element. Defaults to the trigger, and failing that the anchor - which is right for every menu that opens itself. It is the composition case that needs this: bmx-split-button anchors the menu to both of its halves so the list lines up with the primary action's leading edge, but focus belongs on the chevron the user actually pressed. A selector is resolved against the menu's own root, so a composing component can name something inside its own shadow tree without waiting for a ref to exist. |
trigger |
trigger |
string | HTMLElement |
— | The element that opens this menu, as a CSS selector or an element. Given one, the menu takes over its ARIA and its keyboard entirely. Omit it when the composing component already owns a trigger of its own. |
Events
| Event | Detail | Description |
|---|---|---|
bmxOpenChange |
boolean |
Fired when the menu opens or closes. |
bmxSelect |
BmxMenuSelectDetail |
Fired when an item is chosen. |
Methods
| Method | Signature | Description |
|---|---|---|
closeMenu |
closeMenu() => Promise<void> |
Close the menu. Focus returns to whatever opened it. |
openAt |
openAt(x: number, y: number) => Promise<void> |
Open the menu at a point in the viewport, as a context menu. |
openMenu |
openMenu(atEnd?: boolean) => Promise<void> |
Open the menu against its anchor and move focus into it. |
Slots
| Slot | Description |
|---|---|
(default) |
The default slot |
CSS shadow parts
| Part | Description |
|---|---|
item |
A menu item. |
menu |
The root surface. |
separator |
A divider between items. |
submenu |
A cascaded surface. Also matches menu for styling. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-menu-item-padding |
Space inside an item. |
--bmx-menu-max-height |
Tallest a surface grows before it scrolls. |
--bmx-menu-max-width |
Largest width a surface takes before its labels wrap. |
--bmx-menu-min-width |
Smallest width a surface takes. Default 12rem. |
--bmx-menu-padding |
Space between a surface's edge and its items. |
--bmx-menu-radius |
Corner radius of a surface. |