Bridge view
Menu Button
A button that opens a menu of actions or commands — a more document item, a settings list, an overflow ("…") menu of context actions. Distinct from Popover (interactive arbitrary content) by its `role="menu"` semantic and APG menu-keyboard contract; distinct from Select (single-value commit) by invoking actions rather than selecting values; distinct from navigation menus by being a transient action surface, not a persistent navigation region.
When to use
Use
For a list of commands or actions invoked from a single button — More-actions menus, overflow ("⋯") menus, user avatar menus, context-action menus, settings dropdowns. The user opens the menu, picks one command, and the menu closes.
Avoid
For arbitrary interactive content (forms, multi-step flows) — that is `Popover`. For single-value selection from a fixed list — that is `Select`. For navigation between independent pages — that is `SidebarNav` or a navigation list of `Link` elements. For long lists of commands — switch to a Command Palette (see performance threshold).
Versus related
- popover
`Popover` hosts arbitrary interactive content with `role="dialog"`; `MenuButton` hosts a list of commands with `role="menu"` and the APG menu-keyboard contract. The role choice determines the keyboard behaviour: Popover allows Tab into content; MenuButton uses ArrowKeys with Tab closing the menu.
- select
`Select` commits a value (the user picks one from a list); `MenuButton` invokes an action (the user runs one command). Select's `aria-haspopup` is "listbox"; MenuButton's is "menu". Their keyboard contracts differ slightly (Select stays focused on trigger via `aria-activedescendant`; MenuButton moves DOM focus into the menu).
- sidebar-nav
`SidebarNav` is a persistent navigation region with anchors as children; `MenuButton` is a transient action surface invoked on demand. SidebarNav lives in the page layout; MenuButton is portal-mounted and dismisses after one action.
- tooltip
`Tooltip` is non-interactive descriptive text revealed on hover/focus; `MenuButton` opens a list of interactive commands invoked on click/keyboard. They do not share visual or behavioural surface area.
Figma↔Code mismatches
Where designer and developer worlds typically misalign on this component.
- 01 Figma
A "menu" drawn as a Popover with arbitrary content
CodeA `role="menu"` with strict APG menu contract (menuitems only, roving tabindex, ArrowKeys navigate)
ConsequenceDesigners may treat menu and popover as interchangeable — both are floating panels triggered by a button. Implementations following the Figma file ship `role="menu"` with form controls or non-menuitem content inside; SR users hear "menu" and expect menuitem-only content with the menu-keyboard contract; the actual content is something else and the contract breaks.
CorrectDistinguish at the canonical level: Menu = list of commands with `role="menu"` and APG menu-keyboard; Popover = arbitrary interactive content with `role="dialog"` or `role="region"` and Tab-into-content. If the floating surface contains form controls, use Popover, not MenuButton.
- 02 Figma
Menu items drawn as anchor-styled (link-coloured underlined text)
CodeMenu items as `<button>` (or appropriate element with `role="menuitem"`)
ConsequenceDesigners compose menus from link-styled items because menus often "feel like navigation". Implementations following the design ship `<a>` with click handlers that perform actions (not navigate); middle-click opens an empty page; copy-link captures `#`. Or developers ship buttons styled as links — visual confusion remains.
CorrectMenu items inside a MenuButton are buttons (perform actions) by canon. Visual styling may match link styling (underlined, accent colour) but the underlying element is a button or a div with `role="menuitem"` plus the keyboard contract. For navigation menus (where each item navigates to a URL), use SidebarNav or a navigation list, not MenuButton.
- 03 Figma
Submenu drawn as a separate menu component, no parent-child relationship
CodeSubmenus via APG menu-button + menu nesting with `aria-haspopup="menu"` on the parent menuitem
ConsequenceDesigners draw a top-level menu and a submenu as separate components. Implementations following the design wire them as independent menus; the parent menuitem has no `aria-haspopup`, the submenu has no parent reference, keyboard ArrowRight does not open the submenu, ArrowLeft does not close it.
CorrectSubmenus are nested menus where the parent menuitem carries `aria-haspopup="menu"` and `aria-expanded`. Right arrow opens (and moves focus into); Left arrow closes (and returns focus to parent item). The canonical reference documents submenus as a recursive composition pattern but ships single-level menus only in Phase 1 (submenu scope deferred to a follow-up).
- 04 Figma
Caret rotation drawn but no menu enter/exit animation
CodeBoth caret rotation AND menu enter/exit animate together
ConsequenceDesigners animate the caret in mocks but the menu appears instantly (Figma cannot easily mock smooth slide/fade transitions for floating surfaces). Developers shipping faithful-to-mock get jumpy menu transitions; SR users get no announcement while focus moves into the menu.
CorrectCaret rotation and menu enter/exit share the same duration token. Reduced-motion fallback is `instant` — both animations skip together. Document the pairing in the motion block.
Variants, properties, states
Variants
Structurally different versions of the component.
standardicon-only Properties
The same component, parameterised.
| Property | Type |
|---|---|
hasCaret | boolean |
side | top | right | bottom | left |
align | start | center | end |
size | sm | md | lg |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | closedopeningopenclosing |
Figma ↔ Code property map
| Figma | Type | Code | Notes |
|---|---|---|---|
Variant | Variant | variant | Maps standard / icon-only. |
Side | Variant | side | top / right / bottom / left. Authored placement preference; auto-flips on viewport collision. |
Align | Variant | align | start / center / end along the perpendicular axis. |
Size | Variant | size | sm / md / lg. Affects trigger and menu typography. |
Has Caret | Boolean | hasCaret | Toggles the caret slot. Default true for standard variant; default false for icon-only. |
Has Separators | Boolean | separators | Toggles whether menu groups are visually separated. Decorative — does not change keyboard behaviour. |
Item Count | Variant | items.length | Figma exposes 2/3/4/5/6+ item counts as a Variant for preview-time layout review. Code accepts an array of menu item definitions. |
Trigger Label | Text | triggerLabel | For standard variant. Icon-only variant uses aria-label instead. |
Trigger Icon | Instance Swap | triggerIcon | For icon-only variant. Common: kebab (⋮), meatball (⋯), avatar. |
State transitions
| From | To | Trigger |
|---|---|---|
closed | opening | User activates the trigger (Enter / Space / ArrowDown / ArrowUp / click). `aria-expanded` flips to true; the menu mounts; focus moves into the menu landing on first item (or last item for ArrowUp activation per APG). |
opening | open | The enter animation completes (or, under prefers-reduced-motion reduce, immediately). Menu is ready for keyboard navigation; ArrowDown / ArrowUp move between items. |
open | closing | User selects a menuitem (Enter / Space activates the item and closes the menu by canon); presses Escape; tabs out of the menu (Tab / Shift+Tab close); clicks outside the menu; activates a menuitemcheckbox (toggle without close per consumer choice). |
closing | closed | The exit animation completes (or immediately under reduced motion). Focus restores to the trigger. `aria-expanded` flips to false; menu unmounts. |
Figma anatomy
| Slot | Figma type | Hint |
|---|---|---|
trigger | instance | Button instance with optional caret or icon-only treatment; menu state via component property |
caret | instance | Icon component instance; rotation bound to expanded state |
menu | frame | Floating frame; min-inline-size matches trigger by default |
menu-item | instance | Menu item instance; supports leading icon, label, optional shortcut, optional trailing indicator |
separator | rectangle | 1px horizontal line; padding inset from container edges |
Code anatomy
| Slot | Code slot | Semantic |
|---|---|---|
trigger | trigger | button |
caret | caret | presentational |
menu | menu | menu |
menu-item | menu-item | menuitem |
separator | separator | separator |
Cross-framework expression
| Framework | Structure mechanism | Variant mechanism |
|---|---|---|
| Web Components | A `<ui-menu-button>` host with a child `<button>` plus a portal-mounted `<ul role="menu">` of `<li role="menuitem">` children; light DOM for menu item content | attributes (`variant="icon-only"`, `side="bottom"`, `align="start"`, `has-caret`, `size="md"`); `data-state="open|closed|opening|closing"` for CSS |
| React | Radix DropdownMenu (`DropdownMenu.Root` / `DropdownMenu.Trigger` / `DropdownMenu.Portal` / `DropdownMenu.Content` / `DropdownMenu.Item` / `DropdownMenu.CheckboxItem` / `DropdownMenu.RadioItem`); React Aria `useMenuTrigger` plus `useMenu`; Headless UI ships `<Menu>` / `<MenuButton>` / `<MenuItems>` / `<MenuItem>` | props on the root and trigger for variant / size; `data-state` on content for animation; checkbox / radio item subcomponents for stateful items |
| Angular (signals) | Angular CDK Menu (`cdk-menu`, `cdk-menu-item`) plus Overlay for positioning; signal-based menu state | input<'standard' | 'icon-only'>(); input<'top' | 'right' | 'bottom' | 'left'>(); `[hasCaret]`, `[size]` host bindings |
| Vue | Headless UI `<Menu>` / `<MenuButton>` / `<MenuItems>` / `<MenuItem>`; or Radix Vue's DropdownMenu | defineProps with literal-union types; named slots for menu items via `<MenuItem>` children |
Events
openChangeitemSelectcheckedChange
Performance thresholds
switchToCommandPalettemenuitem-count≥15itemsAbove ~15 menu items, scanning the list overwhelms users and typeahead alone is insufficient. Above this threshold, redesign as a Command Palette (a Combobox- like input plus filtered command list) or split into submenus by category. Submenu nesting beyond two levels is itself a redesign signal toward Command Palette.
Internationalisation
RTL · mirroring
Side property is direction-neutral (top/right/bottom/left are physical). Align is logical. Caret moves from inline-end of the trigger (visual right in LTR) to inline-end (visual left in RTL) via logical positioning. Caret rotation is direction-neutral. Menu alignment follows trigger inline-start in both directions. Submenu opens via ArrowRight in LTR / ArrowLeft in RTL — keyboard model follows logical inline direction.
Text expansion
Menu item labels grow with translation; menu inline-size matches longest item by canon. Long-text languages (German, Russian) may exceed common menu widths — design at sm size with care. Trigger label follows Button's expansion rules. Keyboard shortcut indicators (e.g. "⌘K", "Ctrl+K") at the inline-end of menu items are direction-neutral but their position mirrors with the menu inline direction.
Accessibility
| Slot | Accessibility hint | |
|---|---|---|
trigger | Real `<button>` carrying `aria-haspopup="menu"` (or "true" for legacy support; `"menu"` is the modern APG canonical value). `aria-expanded` reflects open state; `aria-controls` references the menu container's id. Icon-only triggers (overflow menus) require an `aria-label` ("More actions", "User menu") because the icon alone is not a label. | |
caret | Decorative — `aria-hidden="true"`. Open state is communicated by `aria-expanded`; the caret visualises it. | |
menu | Apply `role="menu"` and an `id` referenced by the trigger's `aria-controls`. Menu receives DOM focus when opened (unlike Popover where focus stays on trigger). Roving tabindex within the menu — only the currently-focused item has `tabindex="0"`, others have `tabindex="-1"`. | |
menu-item | `role="menuitem"` for plain commands; menuitemcheckbox plus `aria-checked` for toggleables (Bold, Italic); menuitemradio plus `aria-checked` and grouping for mutually-exclusive (text alignment). Disabled items carry `aria-disabled="true"` and stay focusable so SR users hear them; activation is suppressed. | |
separator | `role="separator"` (or no role and `aria-hidden="true"` for purely decorative dividers — APG accepts both). Separators do not receive focus; keyboard navigation skips them. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Enter / Space / ArrowDown (focus on trigger, menu closed) | Opens the menu and moves focus to the first menuitem. `aria-expanded` flips to true; menu mounts. |
ArrowUp (focus on trigger, menu closed) | Opens the menu and moves focus to the LAST menuitem (per APG — convenient for "most recent action" patterns where the user wants the bottom item). |
ArrowDown / ArrowUp (menu open) | Moves focus to the next / previous menuitem. Skips separators and disabled items (focus moves to the next focusable). Wraps from last to first and vice versa. |
Home / End (menu open) | Moves focus to the first / last menuitem. |
Enter / Space (menu open, focus on item) | Activates the item — fires the selection event and closes the menu (for `menuitem` and `menuitemradio`); toggles state and may keep menu open (for `menuitemcheckbox` per consumer choice). |
Escape (menu open) | Closes the menu without activation. Focus restores to the trigger. |
Tab / Shift+Tab (menu open) | Closes the menu and moves focus to the next / previous document focusable. Tab is "I'm done with this menu"; ArrowKeys navigate within. |
typeahead character keys (menu open) | Moves focus to the next menuitem starting with the typed character. Sequential same-character cycles. Multi- character within ~500ms accumulates. |
Screen-reader announcements
| Trigger | Expected |
|---|---|
| Trigger receives focus, menu closed | SR announces "<accessible name>, has popup, button" (or equivalent — exact phrasing varies by SR). The "has popup" portion comes from `aria-haspopup="menu"`. |
| Menu opens | SR announces "expanded" plus the menu's first item. Subsequent ArrowKeys announce each item by label and position ("Cut, 1 of 5"). Menu container itself does not need an accessible name (the trigger labels it implicitly). |
| Item activated | SR announces the item's label one final time before the menu closes; focus returns to trigger; consumer's action runs in parallel. |
| menuitemcheckbox toggled | SR announces the new checked state ("Bold, checked, menu item" or "Bold, not checked, menu item"). Driven by `aria-checked` on the item. |
axe-core rules to assert
aria-allowed-rolearia-required-attraria-required-childrenaria-required-parentaria-valid-attr-valuecolor-contrastrole-img-alt
Common mistakes
#menubutton-no-aria-haspopup
Trigger missing `aria-haspopup`
The trigger is a styled button with no `aria-haspopup`. SR users hear "button" with no signal that activation opens a menu. Combined with missing `aria-expanded`, the relationship to the menu is invisible.
Trigger always carries `aria-haspopup="menu"` (or `"true"` for legacy SR support — modern canonical is `"menu"`). `aria-expanded` toggles in sync with the open state. `aria-controls` references the menu's id.
#menubutton-arrowdown-doesnt-open
ArrowDown on trigger does not open the menu
The trigger opens the menu only on click / Enter / Space. APG canonical behaviour requires ArrowDown / ArrowUp to also open (and ArrowUp to land focus on the last item). Keyboard users expecting the canonical behaviour cannot navigate efficiently.
Bind ArrowDown and ArrowUp on the trigger: ArrowDown opens the menu and focuses the first item; ArrowUp opens and focuses the last item. Click / Enter / Space focus the first item by canon (some implementations focus none and require a subsequent ArrowKey; APG canonical is to focus first).
#menubutton-no-typeahead
Typeahead by first-letter not implemented
User types a letter expecting to jump to the next menuitem starting with that letter (a canonical APG behaviour). Nothing happens; users with long menus arrow-key through every entry.
Implement first-letter typeahead on the open menu: typing matches the first menuitem starting with that letter and moves focus there. Sequential same-letter cycles through matches. Multi-character timeout (~500ms) accumulates the buffer.
#menubutton-tab-traps-in-menu
Tab cycles within the menu instead of closing
Implementation copies the focus-trap pattern from Modal into the menu. Tab cycles between menuitems instead of moving past the menu and closing it. Keyboard users cannot escape the menu without Escape; cyclical Tab feels broken because menus are non-modal.
Tab on an open menu MOVES focus to the next document focusable AND closes the menu (canonical APG). Shift+Tab same in reverse. ArrowDown / ArrowUp navigate within; Tab is the explicit "I'm done with this menu" key.
#menubutton-no-roving-tabindex
Every menuitem has tabindex="0"
Implementation gives every menuitem a `tabindex="0"`. Tab reaches every item one by one; the menu inflates the page's tab order with `n` extra stops. APG canonical is roving tabindex (only the focused item has `tabindex="0"`, others have `tabindex="-1"`).
Only the currently-focused menuitem has `tabindex="0"`; all others have `tabindex="-1"`. ArrowKeys move the `tabindex="0"` reference along with focus. Tab moves out of the menu (one stop, not n).