Menu

.menu[popover][role="menu"][data-ui="menu"]

Menu — a dropdown built on .popover.

Examples

An actions menu — role="menu", and the keyboard contract that goes with it

View “An actions menu — role="menu", and the keyboard contract that goes with it” as
<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.

The same menu without the behaviour — and without the roles

View “The same menu without the behaviour — and without the roles” as
<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.

A navigation menu — which is not a menu

View “A navigation menu — which is not a menu” as
<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.

A mega menu

View “A mega menu” as
<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.

Level 2 tokens declared by menu
TokenDefault
--menu-item-active-bgvar(--color-surface-sunken)
--menu-item-bgtransparent
--menu-item-colorvar(--color-on-surface)
--menu-item-current-colorvar(--color-primary)
--menu-item-danger-colorvar(--color-danger)
--menu-item-danger-hover-bgvar(--color-danger-surface)
--menu-item-font-sizevar(--font-size-sm)
--menu-item-gapvar(--space-2)
--menu-item-hover-bgvar(--color-surface-sunken)
--menu-item-min-sizevar(--target-size-compact)
--menu-item-padding-blockvar(--space-2)
--menu-item-padding-inlinevar(--space-3)
--menu-item-radiusvar(--radius-field)
--menu-label-colorvar(--color-on-surface-muted)
--menu-label-font-sizevar(--font-size-xs)
--menu-separator-colorvar(--color-border-subtle)
--menu-shortcut-colorvar(--color-on-surface-subtle)
--menu-min-inline-size12rem
--menu-paddingvar(--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

  1. Pick one of the three markups above and copy all of it. Then: Name it. aria-labelledby pointing at the invoking button, or an aria-label. Every one of the three needs this. Put a data-ui on the panel, whichever markup you chose. menu for 1, popover for 2 and 3. Both supply the invoker's aria-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 write aria-haspopup by 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 real role="menu" and nowhere else. Match the roles to the pattern, all of them or none. A role="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