Dev 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.

Highlight
Fig 1.1 · Menu Button · Dev view
Dev

Code anatomy

Slot Code slot Semantic
trigger trigger button
caret caret presentational
menu menu menu
menu-item menu-item menuitem
separator separator separator
Both

Variants, properties, states

Variants

Structurally different versions of the component.

standardicon-only

Properties

The same component, parameterised.

PropertyType
hasCaretboolean
sidetop | right | bottom | left
alignstart | center | end
sizesm | md | lg

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
closedopeningopenclosing
Both

State transitions

FromToTrigger
closedopeningUser 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).
openingopenThe enter animation completes (or, under prefers-reduced-motion reduce, immediately). Menu is ready for keyboard navigation; ArrowDown / ArrowUp move between items.
openclosingUser 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).
closingclosedThe exit animation completes (or immediately under reduced motion). Focus restores to the trigger. `aria-expanded` flips to false; menu unmounts.
Dev

Cross-framework expression

FrameworkStructure mechanismVariant 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
Both

Events

  1. openChange
    Payload
    Boolean. `true` when the menu opens, `false` when it closes. Fires after the transition settles, not at the start. Mirrors `aria-expanded` on the trigger.
    Web Components
    `openChange` CustomEvent on the host with `event.detail = { open: boolean }`.
    React
    `onOpenChange(open: boolean)` (Radix DropdownMenu, Headless UI Menu).
    Angular Signals
    `output<boolean>('openChange')`.
    Vue
    `@update:open` for `v-model:open`.
  2. itemSelect
    Payload
    `{ itemId: string }` — the canonical id of the activated menuitem. Consumer handles the action (running a command, toggling a state). Menu closes by default after item selection (canonical for `menuitem` and `menuitemradio`); `menuitemcheckbox` may keep the menu open per consumer choice.
    Web Components
    `itemSelect` CustomEvent with `event.detail = { itemId }`.
    React
    Per-item `onSelect(event)` on `DropdownMenu.Item` (Radix); Headless UI uses `onClick` on `<MenuItem>` children.
    Angular Signals
    `output<string>('itemSelect')` on the menu component.
    Vue
    `@select` event on the menu item or per-item `@click`.
  3. checkedChange
    Payload
    `{ itemId: string, checked: boolean }` — fires for menuitemcheckbox or menuitemradio activation. Distinct from `itemSelect` because checkbox toggles state without committing a single action; consumer typically updates its own state and may keep the menu open.
    Web Components
    `checkedChange` CustomEvent with `event.detail = { itemId, checked }`.
    React
    `onCheckedChange(checked)` on `DropdownMenu.CheckboxItem` (Radix); per-item callbacks.
    Angular Signals
    `output<{ itemId: string, checked: boolean }>('checkedChange')`.
    Vue
    `@update:checked` on the relevant menu item.
Dev

Performance thresholds

  • switchToCommandPalettemenuitem-count15items

    Above ~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.

Both

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.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
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

TriggerExpected
Trigger receives focus, menu closedSR announces "<accessible name>, has popup, button" (or equivalent — exact phrasing varies by SR). The "has popup" portion comes from `aria-haspopup="menu"`.
Menu opensSR 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 activatedSR announces the item's label one final time before the menu closes; focus returns to trigger; consumer's action runs in parallel.
menuitemcheckbox toggledSR 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-role
  • aria-required-attr
  • aria-required-children
  • aria-required-parent
  • aria-valid-attr-value
  • color-contrast
  • role-img-alt
Dev

Common mistakes

#menubutton-no-aria-haspopup

Trigger missing `aria-haspopup`

Problem

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.

Fix

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

Problem

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.

Fix

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

Problem

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.

Fix

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

Problem

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.

Fix

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"

Problem

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"`).

Fix

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).

Figma↔Code mismatches
  1. 01
    Figma

    A "menu" drawn as a Popover with arbitrary content

    Code

    A `role="menu"` with strict APG menu contract (menuitems only, roving tabindex, ArrowKeys navigate)

    Consequence

    Designers 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.

    Correct

    Distinguish 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.

  2. 02
    Figma

    Menu items drawn as anchor-styled (link-coloured underlined text)

    Code

    Menu items as `<button>` (or appropriate element with `role="menuitem"`)

    Consequence

    Designers 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.

    Correct

    Menu 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.

  3. 03
    Figma

    Submenu drawn as a separate menu component, no parent-child relationship

    Code

    Submenus via APG menu-button + menu nesting with `aria-haspopup="menu"` on the parent menuitem

    Consequence

    Designers 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.

    Correct

    Submenus 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).

  4. 04
    Figma

    Caret rotation drawn but no menu enter/exit animation

    Code

    Both caret rotation AND menu enter/exit animate together

    Consequence

    Designers 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.

    Correct

    Caret 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.