Skip to content
RU
Scale
Density
Theme
Accent

Overlays

Menu

JS The arrows and the roving tabindex are done by `instrument.js`. Opening and closing are taken on by the Popover API

A list of actions on an object. A menu is the content of a popover rather than an overlay in its own right: the top layer, the closing and the focus come from there.

The component
Markup
<button class="inst-btn" type="button" popovertarget="menu-run">Actions</button>
<div class="inst-popover inst-popover--anchored" id="menu-run" popover>
  <div class="inst-menu" role="menu">
    <span class="inst-menu-label">Run #4127</span>
    <button class="inst-menu-item" type="button" role="menuitem">
      <svg class="inst-icon" aria-hidden="true"><use href="#i-refresh"/></svg>Restart
      <span class="inst-menu-shortcut"><kbd>R</kbd></span></button>
    <button class="inst-menu-item" type="button" role="menuitem">
      <svg class="inst-icon" aria-hidden="true"><use href="#i-copy"/></svg>Copy the id</button>
    <button class="inst-menu-item" type="button" role="menuitem" aria-checked="true">
      <svg class="inst-icon" aria-hidden="true"><use href="#i-list"/></svg>Show the log</button>
    <span class="inst-menu-sep"></span>
    <button class="inst-menu-item" type="button" role="menuitem" data-tone="error">Delete the run</button>
    <button class="inst-menu-item" type="button" role="menuitem" aria-disabled="true">Archive</button>
  </div>
</div>

Contract

What Required Why
A role="menu" on the container yes Without it a role="menuitem" is invalid
A role="menuitem" on the items yes The library draws the state but does not invent the role
A type="button" on items that are buttons yes Otherwise inside a form the menu will submit it
An aria-hidden="true" on the icon yes The name of an item is given by the label rather than by the icon
The wrapper of a popover yes A menu does not pop up by itself: the top layer and the closing come from the popover

Moving by arrows and the roving tabindex are done by instrument.js. Opening, closing on Escape and on a click outside, and returning the focus to the button call for no script at all — they are taken on by the Popover API.

Accessibility

The keyboard Tab to the button, Enter to open, Escape to close. The arrows inside the menu are set by the application: without them a menu is traversed by tab, which for a long list is agony
The roles A role="menu" + a role="menuitem" are required. An aria-checked with no role="menuitem" is not spoken
An unavailable item aria-disabled="true" rather than disabled: the item stays in the arrow traversal and gets a roving tabindex but is not chosen — neither a click nor Enter calls the application (behaviour). A disabled removes the item from the traversal by the platform's own means, and the action is never learned of
Colour is not the only carrier A destructive item is red and named by the word "Delete". A checked one carries an aria-checked rather than a colour alone
The tap target An item of height --control-h-sm across the whole width of the popover: it is easier to hit with a mouse than the text
Wrapping white-space: nowrap — an item does not wrap. Shorten a long label rather than hoping for a wrap

Anatomy

The label of a group, the divider and the hotkey are three different jobs, and confusing them is expensive.

<div class="inst-menu" role="menu">
  <span class="inst-menu-label">Run #4127</span>
  <button class="inst-menu-item" type="button" role="menuitem">Restart
    <span class="inst-menu-shortcut"><kbd>R</kbd></span></button>
  <span class="inst-menu-sep"></span>
  <button class="inst-menu-item" type="button" role="menuitem">Export</button>
</div>
Class The work
inst-menu-label The label of a group. Not interactive and not in the traversal
inst-menu-item An item. A <button> for an action, an <a> for a transition
inst-menu-sep The divider between groups. A <span> rather than an <hr>: it is styling, and there is nothing to speak
inst-menu-shortcut A hotkey at the far edge

A hotkey is pushed to the far edge and is quieter than the label: it is a hint rather than a second name of the item.

An item is a <button> or an <a>, by the work. An action is a button, a transition to an address a link: a button leading to an address breaks the middle click and "open in a new tab".

States

Example
Markup
<div class="inst-popover" popover id="menu-kinds">
  <div class="inst-menu" role="menu">
    <span class="inst-menu-label">Kind</span>
    <button class="inst-menu-item" type="button" role="menuitem">Ordinary</button>
    <button class="inst-menu-item" type="button" role="menuitem" aria-checked="true">Checked</button>
    <a class="inst-menu-item" href="#menu" role="menuitem" aria-current="page">The current address</a>
    <span class="inst-menu-sep"></span>
    <button class="inst-menu-item" type="button" role="menuitem" data-tone="error">Destructive</button>
    <button class="inst-menu-item" type="button" role="menuitem" aria-disabled="true">Unavailable</button>
  </div>
</div>
<button class="inst-btn" type="button" popovertarget="menu-kinds">Kinds of item</button>

An item with an explanation. The .inst-menu-item-main wrapper makes the item two lines by itself — there is no modifier for that, because it is not a choice of the author of the markup but a consequence of what is inside. A menu of choice with no explanation turns into a list of code words.

Example
Markup
<div class="inst-popover" popover id="menu-model">
  <div class="inst-menu" role="menu">
    <button class="inst-menu-item" type="button" role="menuitem">
      <span class="inst-menu-item-main">Fast
        <span class="inst-menu-item-sub">For short runs</span></span></button>
    <button class="inst-menu-item" type="button" role="menuitem" aria-checked="true">
      <span class="inst-menu-item-main">Full
        <span class="inst-menu-item-sub">For complex tasks</span></span></button>
    <button class="inst-menu-item" type="button" role="menuitem">
      <span class="inst-menu-item-main">Thrifty
        <span class="inst-menu-item-sub">Cheapest of all on everyday work</span></span></button>
  </div>
</div>
<button class="inst-btn" type="button" popovertarget="menu-model">Run mode</button>
The state of an item How it is set What happens
ordinary — --text-primary
hover :hover --surface-hover
checked aria-checked="true" --accent-text and a medium weight
current aria-current (any value but false) The same styling as a checked one
destructive data-tone="error" --err-text, and --err-bg on hover
unavailable aria-disabled="true" or disabled An opacity of 0.5, the mouse removed

JS

Include the module once per page — there is no need to initialise the components one by one, instrument.js works by delegation and sees nodes that arrived later.

<script type="module" src="instrument.js"></script>

What instrument.js does

The example in the header is live: open the menu and go through the items with the arrows.

Arrows between the items, Home, End, a traversal that wraps and a roving tabindex. An item has no selection and will have none: a menu item is an action rather than a choice, and an aria-selected on it would lie to assistive technology.

Opening, closing, returning the focus and closing on Escape are taken on by the Popover API — they call for no script.

Events

There are none of its own. An item is a <button>, and its click works the same way as everywhere.

menu.addEventListener('click', (e) => {
  const item = e.target.closest('[role="menuitem"]');
  if (!item) return;
  menu.hidePopover();
  run(item.dataset.action);
});

The menu is closed before the action: the action may open a modal, and the menu would be left hanging over it.

API

Classes
NameWhat it does
class
inst-menuThe container. A column of items with a gap of --space-1
inst-menu-itemAn item: a <button> or an <a>
inst-menu-item-mainThe wrapper of the name and the explanation. Its presence makes the item two lines — there is no modifier for that
inst-menu-item-subThe explanation under the name. For a menu of choice, where a name alone says nothing
inst-menu-labelThe label of a group. Not interactive
inst-menu-sepA divider a hairline thick
inst-menu-shortcutA hotkey at the far edge of an item
Attributes
NameValueWhat it does
attribute
data-tone—neutral running ok warn error. Closed. On an item only error makes sense
aria-checked—true — the item is checked
aria-current—Any value but false
aria-disabled—true — the item is unavailable
Tokens
NameValueWhere from
token
--space-12pxone across every cell
--space-24pxone across every cell
--space-36pxone across every cell
--space-616pxone across every cell
--gap-inlinevar(--space-4)base +14: density · scale
--control-h-sm24pxbase +14: density · scale
--radius-sm6pxbase +4: scale
--text-2xs0.6875rembase +4: scale
--text-sm0.875rembase +4: scale
--weight-medium600one across every cell
--hairline1pxbase +1: screen
--border-subtlelight-dark(oklch(0 0 0 / 0.07), oklch(1 0 0 / 0.06))one across every cell
--surface-hoverlight-dark(oklch(0 0 0 / 0.035), oklch(1 0 0 / 0.045))one across every cell
--accent-textlight-dark(var(--a-5), var(--a-3))base +1: theme
--err-textlight-dark(var(--err-5), var(--err-3))base +1: theme
--err-bglight-dark(var(--err-1), color-mix(in oklab, var(--err-4) 16%, transparent))one across every cell
--text-mutedlight-dark(var(--n-8), var(--n-6))one across every cell
--leading-ui1.4one across every cell

Why the kit works this way — the constitution · Open source under MIT, github.com/keshon/instrument