Menu
A transient list of commands raised by a trigger. Everything the industry calls a "dropdown" that runs an action rather than setting a value is this component.
Also called Dropdown Menu, Overflow Menu, Kebab Menu, Context Menu, Action Menu, More Menu — in this system all of them are Menu.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Row actions
The commonest menu in any product. Each trigger names its row, so forty menus are forty distinct controls rather than forty buttons called "More".
Checkable items
A column picker uses menuitemcheckbox and stays open while the user toggles. Closing after each choice would cost four round trips to configure four columns.
Separators do the grouping
Edit actions, then export actions, then the destructive one at the end. Eight ungrouped rows is a list to read; three groups is a decision to make.
Shortcuts belong on the row
The menu is where users discover the keyboard. Right-aligned, muted, and never the only place the shortcut is documented.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Three groups separated by hairlines, with the destructive action last. Icons align in a fixed gutter so the labels form one column.
- Panel widthmin 11rem, max 18rem
Sized to the longest label. Capped so a stray long item wraps rather than stretching the panel across the viewport.
- Row height30px (44px on touch)
Dense, because the panel is transient and scanned as a list. Two-line rows with descriptions go to 44px everywhere.
- Icon gutter14px icon, fixed column
The gutter is reserved even for items with no icon, so labels stay on one left edge. A ragged label column is the fastest way to make a menu feel unfinished.
- Separator1px, 4px margins
role="separator". It is the grouping, and it is what puts a stop in front of the destructive row.
- ShortcutRight-aligned, muted
Never the only documentation of the shortcut, but the place most people learn it.
- Offset6px from the trigger
Close enough to read as attached; far enough that the trigger’s focus ring is not clipped by the panel.
- Elevation--shadow-e4
Above the page, below a dialog. A menu is transient, and a dialog opened from one must clearly sit above it.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-overlay | — | Panel surface |
| --ds-border | — | Panel edge |
| --ds-border-subtle | — | Separators |
| --ds-fg-secondary | — | Item labels |
| --ds-fg-muted | — | Icons and shortcuts |
| --ds-layer-hover | — | Active row fill |
| --ds-danger-text | — | Destructive item label and icon |
| --ds-accent-text | — | Check mark on a checkable item |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-1 | Panel padding and separator margins |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Panel corners | |
| --radius-md | Row corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e4 | — | Panel elevation |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Entrance and chevron rotation |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Icon | Label gap | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|---|---|
| Panel | — | 4px | 12px | — | — | — | 11rem | 18rem | — | Sized to the longest label, capped so nothing stretches the layout. |
| Row | 30px | 0 8px | — | — | 10px | 13px | — | — | 44px on coarse pointers | The default. |
| Row with description | 44px | 6px 8px | — | — | — | — | — | — | — | Two lines. Use sparingly — a menu of descriptions is a list, not a menu. |
| Icon | — | — | — | 14px | — | — | — | — | — | One step below body. Reserved as a gutter even on items without one. |
| Separator | 1px | — | — | — | 4px | — | — | — | — | Full panel width, inside the padding. |
| Submenu | — | — | — | — | — | — | 10rem | — | — | Opens on the trigger’s inline edge, flipping when it would leave the viewport. |
aria-label="More actions for api-gateway"onClose → triggerRef.current?.focus()Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Item labels owe 4.5:1 — the label is the content. Icons and shortcuts are supplementary and may sit lower, but not below 3:1.
- The active row must be distinguishable from idle rows without relying on colour alone at a 30px row height, where a faint wash disappears in daylight.
- The destructive item carries both the danger colour and its own icon, so the severity survives greyscale.
- The panel edge must reach 3:1 against the page behind it. On an overlay surface in dark mode this is easy to lose.
Keyboard
| Enter / Space / ↓ | On the trigger, opens the menu and focuses the first item. |
| ↑ | On the trigger, opens the menu and focuses the last item. |
| ↑ / ↓ | Moves between items, wrapping at both ends. Disabled items are skipped. |
| Home / End | Jumps to the first or last item. |
| A–Z | Typeahead — jumps to the next item starting with that letter. Free with role="menu" and worth wiring up. |
| → | Opens a submenu and focuses its first item. ← closes it and returns. |
| Enter | Runs the item. Commands close the menu; checkable items leave it open. |
| Esc | Closes and returns focus to the trigger. |
| Tab | Closes the menu and moves on. A menu is never tabbed through — that is what the arrows are for. |
Screen readers
- The trigger announces as "More actions for api-gateway, menu button, collapsed".
- Announce the item count when the menu opens: "menu, 7 items". Without it a user has to arrow to the end to learn the length.
- A checkable item announces its state: "Status, menu item checkbox, checked". If the check mark is the only signal, that state does not exist for a screen-reader user.
Focus & touch
- Opening moves focus into the menu; closing returns it to the trigger, always. Focus is managed with roving tabindex rather than by making every item tabbable — Tab must exit the menu entirely, not walk through it.
- Rows grow to 44px and submenus are replaced entirely — nest nothing on touch, because there is no hover and no diagonal path. On a phone, a menu with more than about six items should become a Drawer from the bottom edge, which is reachable and scrollable in a way a floating panel is not.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-haspopup="menu" | The trigger | Announces that this button opens something rather than doing something. |
| aria-expanded | The trigger | Must track the real state. Hardcoded false is a silent, common bug. |
| role="menu" | The panel | A contract: arrow navigation, Tab exits, commands only. Do not use it for a panel with mixed content. |
| role="menuitem" | Command rows | menuitemcheckbox with aria-checked for toggles; menuitemradio for a single-choice group. |
| role="separator" | Dividers | So the grouping reaches anyone who cannot see the hairline. |
| aria-label | The panel | Names its scope: "Actions for api-gateway". Otherwise the panel is anonymous. |
Example usage
1import { MenuList, Popover } from '@/ui/Overlay'23<Popover4 align="end"5 trigger={({ toggle, open }) => (6 <IconButton7 // Names the ROW, not the control. Forty "More options" buttons are8 // forty identical announcements.9 label={`More actions for ${project.name}`}10 icon={<MoreHorizontal />}11 aria-haspopup="menu"12 aria-expanded={open}13 onClick={toggle}14 />15 )}16>17 <MenuList18 label={`Actions for ${project.name}`}19 items={[20 { label: 'Rename', icon: <Pencil />, shortcut: '⌘E', onSelect: rename },21 { label: 'Duplicate', icon: <Copy />, shortcut: '⌘D', onSelect: duplicate },22 'separator',23 // Last, past a separator, so the pointer never crosses it on the way24 // to something safe. And it confirms rather than running.25 { label: 'Delete', icon: <Trash2 />, danger: true, onSelect: confirmDelete },26 ]}27 />28</Popover>2930// Checkable items keep the menu open — four columns should cost four clicks,31// not four clicks plus four reopenings.32<div role="menu" aria-label="Visible columns">33 {columns.map((c) => (34 <button35 key={c.id}36 role="menuitemcheckbox"37 aria-checked={visible.includes(c.id)}38 onClick={() => toggle(c.id)} // no close39 >40 {c.label}41 </button>42 ))}43</div>Framework-free HTML
<button
type="button"
id="row-trigger"
aria-label="More actions for api-gateway"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="row-menu"
>
<svg aria-hidden="true">…</svg>
</button>
<div id="row-menu" role="menu" aria-label="Actions for api-gateway" hidden>
<!-- Roving tabindex: Tab EXITS a menu, it never walks through it. -->
<button type="button" role="menuitem" tabindex="0">
<svg aria-hidden="true">…</svg>
Rename
<kbd>⌘E</kbd>
</button>
<button type="button" role="menuitem" tabindex="-1">Duplicate</button>
<div role="separator"></div>
<button type="button" role="menuitem" tabindex="-1" class="is-danger">
Delete
</button>
</div>CSS
[role='menu'] {
min-inline-size: 11rem;
max-inline-size: 18rem; /* a long label wraps, never stretches */
padding: 4px;
border: 1px solid var(--ds-border);
border-radius: var(--radius-lg);
background: var(--ds-surface-overlay);
box-shadow: var(--shadow-e4); /* above the page, below a dialog */
}
[role='menuitem'],
[role='menuitemcheckbox'] {
display: flex;
align-items: center;
gap: 10px;
inline-size: 100%;
block-size: 30px;
padding-inline: 8px;
border-radius: var(--radius-md);
font-size: 13px;
color: var(--ds-fg-secondary);
}
/* The gutter is reserved even when there is no icon, so the labels form one
column. A ragged label edge is what makes a menu look unfinished. */
[role='menuitem'] > .ds-menu__icon {
inline-size: 14px;
flex: 0 0 auto;
color: var(--ds-fg-muted);
}
[role='menuitem']:hover,
[role='menuitem'][data-active='true'] {
background: var(--ds-layer-hover);
color: var(--ds-fg);
}
.is-danger,
.is-danger .ds-menu__icon { color: var(--ds-danger-text); }
[role='separator'] {
block-size: 1px;
margin-block: 4px;
background: var(--ds-border-subtle);
}
@media (pointer: coarse) {
[role='menuitem'] { block-size: 44px; }
/* No hover, no diagonal path: submenus do not work here at all. */
.ds-menu__submenu { display: none; }
}Component API
MenuList
| Prop | Type | Default | Description |
|---|---|---|---|
| items* | (MenuItemSpec | 'separator')[] | — | Commands in order, grouped by separators, destructive last. |
| label* | string | — | Names the menu’s scope. Becomes aria-label on the panel. |
| onClose | () => void | — | Called after a command runs. Omit for checkable menus that must stay open. |
MenuItemSpec
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | string | — | Lead with the verb: "Delete project", not "Project deletion". |
| icon | ReactNode | — | 14px. Optional per item; the gutter is reserved regardless. |
| shortcut | string | — | Right-aligned hint. Never the only place the shortcut is documented. |
| danger | boolean | false | Danger tone. Should be paired with a confirmation rather than running immediately. |
| disabled | boolean | false | Skipped by arrow keys but kept in place, so nothing shifts when it becomes available. |
| onSelect | () => void | — | Runs the command. Commands close the menu; checkable items should not. |
Professional tips
- Cap a menu at about eight items. Past that it is a list that needs search, or a panel that needs sections with headings.
- Lead every label with a verb. "Delete project" scans and sorts better than "Project deletion", and it reads correctly when announced.
- Keep the item order stable across rows and states. Users learn "Delete is at the bottom" in one session and rely on it forever.
- Disable rather than remove unavailable items, and explain why in the row. An item that vanishes moves everything below it while the pointer is in flight.
- On mobile, promote a long menu into a bottom Drawer. A floating panel near the top of a phone screen is outside the thumb zone entirely.
Performance
- Do not mount a menu until it opens. A table of two hundred rows each holding a hidden panel is two hundred popovers of layout work nobody sees.
- Compute placement on open, not on every scroll frame. Anchoring math in a scroll listener is the classic cause of jank in long tables.
- Share one menu instance across a table and re-point it at the active row. Two hundred identical panels is two hundred times the memory for one visible thing.
- Animate the panel with transform and opacity only. Animating height re-lays-out the panel and the rows visibly jump.
Common mistakes
- role="menu" on a panel containing inputs or links, breaking arrow navigation and the meaning of Enter.
- A trigger named "More options" repeated on every row, leaving assistive tech with dozens of identical controls.
- aria-expanded hardcoded to false, so the open state is never announced.
- Focus dropped to the body on close instead of returning to the trigger.
- Closing on every click in a checkable menu, so configuring four columns takes eight interactions.
- Destructive items in the middle of the list, directly on the pointer’s path to something safe.
- Two levels of submenu, which fails on touch and on trackpads with acceleration.
Real-world recommendations
- The "⋯" trigger is now universally understood, but only in a row or card context. Floating on its own with nothing to scope it, users do not know what it will act on.
- Right-click context menus are worth adding only where the surrounding product already has them — an editor, a file tree, a canvas. In a form-based app, nobody tries.
- Menus are where power users learn shortcuts. Products that show them see measurably higher keyboard adoption, which is the cheapest performance win available.
- If a menu item is used constantly, that is the signal to promote it to a real button. The menu is the staging area, not the destination.