Menu
Menu — a dropdown built on .popover.
Examples
<button class="btn btn-outline" type="button" id="demo-menu-button"
command="toggle-popover" commandfor="demo-menu">Actions</button>
<div class="menu popover popover-plain" id="demo-menu" popover
role="menu" aria-labelledby="demo-menu-button" data-ui="menu">
<button class="menu-item" type="button" role="menuitem">
Duplicate
<span class="menu-shortcut">⌘D</span>
</button>
<button class="menu-item" type="button" role="menuitem">Rename</button>
<button class="menu-item menu-item-parent" type="button" role="menuitem"
command="toggle-popover" commandfor="demo-menu-share">Share</button>
<hr class="menu-separator" />
<button class="menu-item menu-item-danger" type="button" role="menuitem">Delete</button>
</div>
<div class="menu popover popover-plain popover-inline-end" id="demo-menu-share" popover
role="menu" aria-label="Share" data-ui="menu">
<button class="menu-item" type="button" role="menuitem">Copy link</button>
<button class="menu-item" type="button" role="menuitem">Email</button>
</div>Open it and use only the keyboard: arrows move, Home and End jump, typing a letter jumps, Arrow Right opens the submenu and Arrow Left closes it, Tab closes the menu, Escape closes it and returns focus to the button.
The opening, the top layer, light dismiss and Escape are the browser’s. aria-expanded is not — invoker commands supply none, on any engine — so data-ui="menu" adds that along with the keys role="menu" promises and no element implements. The submenu keeps its parent open because its trigger is inside the parent — that relationship is native too.
<button class="btn btn-outline" type="button" id="demo-menu-plain-button"
command="toggle-popover" commandfor="demo-menu-plain">Actions</button>
<div class="menu popover popover-plain" id="demo-menu-plain" popover
aria-labelledby="demo-menu-plain-button" data-ui="popover">
<button class="menu-item" type="button">Duplicate</button>
<button class="menu-item" type="button">Rename</button>
<hr class="menu-separator" />
<button class="menu-item menu-item-danger" type="button">Delete</button>
</div>Identical to the eye, and not a degraded version of the one above — it is a correct component.
No role="menu", no role="menuitem", no aria-haspopup: Tab walks the buttons, each announces as a button, and nothing claims a contract it cannot keep. role="menu" and data-ui="menu" ship together, because a menu without the behaviour tells a screen reader user the arrow keys work when they do not — a missing affordance is a plainer experience, a lying one is a dead end. If you are not shipping the library’s JavaScript, this is the markup you want. data-ui="popover" is still there for aria-expanded, which no engine supplies on its own.
<button class="btn btn-quiet" type="button"
command="toggle-popover" commandfor="demo-menu-nav">Products</button>
<div class="menu popover popover-plain" id="demo-menu-nav" popover data-ui="popover">
<ul class="menu-list">
<li><a class="menu-item" href="#demo-menu-nav" aria-current="page">Analytics</a></li>
<li><a class="menu-item" href="#demo-menu-nav">Reports</a></li>
<li><a class="menu-item" href="#demo-menu-nav">Integrations</a></li>
</ul>
</div>No role="menu", no role="menuitem", no aria-haspopup, no data-ui="menu" — and the module refuses to initialise without the role, rather than quietly applying menu keys to a list of links.
The test is whether choosing an item navigates or does something: links navigate, and links are a list that Tab already walks. aria-current="page" is what marks the current one, to the eye and to a screen reader at once.
<button class="btn btn-quiet" type="button"
command="toggle-popover" commandfor="demo-menu-mega">Platform</button>
<div class="menu menu-mega popover" id="demo-menu-mega" popover data-ui="popover">
<div class="menu-column">
<p class="menu-label">Build</p>
<ul class="menu-list">
<li><a class="menu-item" href="#demo-menu-mega">Editor</a></li>
<li><a class="menu-item" href="#demo-menu-mega">Components</a></li>
</ul>
</div>
<div class="menu-column">
<p class="menu-label">Measure</p>
<ul class="menu-list">
<li><a class="menu-item" href="#demo-menu-mega">Analytics</a></li>
<li><a class="menu-item" href="#demo-menu-mega">Experiments</a></li>
</ul>
</div>
<div class="menu-column">
<p class="menu-label">Operate</p>
<ul class="menu-list">
<li><a class="menu-item" href="#demo-menu-mega">Deployments</a></li>
<li><a class="menu-item" href="#demo-menu-mega">Status</a></li>
</ul>
</div>
</div>A mega menu is the place the wrong role is applied most often, because it looks the least like a list — and it is still a set of <ul>s of links.
The columns are auto-fit, so the panel reflows to one column on a narrow screen with no breakpoint.
Tokens 19
Level 2, declared on .menu itself. Set any of them on that selector to restyle this component without touching the skin.
| Token | Default |
|---|---|
| --menu-item-active-bg | var(--color-surface-sunken) |
| --menu-item-bg | transparent |
| --menu-item-color | var(--color-on-surface) |
| --menu-item-current-color | var(--color-primary) |
| --menu-item-danger-color | var(--color-danger) |
| --menu-item-danger-hover-bg | var(--color-danger-surface) |
| --menu-item-font-size | var(--font-size-sm) |
| --menu-item-gap | var(--space-2) |
| --menu-item-hover-bg | var(--color-surface-sunken) |
| --menu-item-min-size | var(--target-size-compact) |
| --menu-item-padding-block | var(--space-2) |
| --menu-item-padding-inline | var(--space-3) |
| --menu-item-radius | var(--radius-field) |
| --menu-label-color | var(--color-on-surface-muted) |
| --menu-label-font-size | var(--font-size-xs) |
| --menu-separator-color | var(--color-border-subtle) |
| --menu-shortcut-color | var(--color-on-surface-subtle) |
| --menu-min-inline-size | 12rem |
| --menu-padding | var(--space-1) |
Variants and states
Variants
.popover
Inside it
.menu-item.menu-column.menu-item-parent.menu-label.menu-list.menu-mega.icon.menu-item-danger.menu-separator.menu-shortcut
State it reads
Read from the platform, never mirrored into a class that could disagree with it.
:disabled:focus:hover:popover-open
Before you ship it
This one needs a script. Opt the markup in with data-ui="menu" and import import 'mostlycss/js/components/menu' — that specifier and not the package root, which is a bundle carrying a second copy of the registry, so a page holding both initialises every element twice. What you lose without the script is listed below — for some components that is only polish, for others it is the whole interaction.
What you have to do 1 requirements
- Pick one of the three markups above and copy all of it. Then: Name it.
aria-labelledbypointing at the invoking button, or anaria-label. Every one of the three needs this. Put adata-uion the panel, whichever markup you chose.menufor 1,popoverfor 2 and 3. Both supply the invoker'saria-expanded, which no engine provides for an invoker command — measured on all three; see popover.css. Markup 2 and 3 need it just as much as 1: their buttons are every bit as silent without it. Do not writearia-haspopupby hand.data-ui="menu"adds it after verifying the role, which is the only way it can be honest — it belongs on a button that opens a realrole="menu"and nowhere else. Match the roles to the pattern, all of them or none. Arole="menu"whose children are plain buttons is a menu with no items, which is worse than either consistent choice.
src/css/components/menu.css · npx mostlycss add menu