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.

Also called Split button Dropdown button

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.

  • button

    `Button` invokes one action on activation; `MenuButton` reveals a popup list of related actions and lets the user pick. Reach for MenuButton when more than ~3 commands share a single trigger surface (overflow menus, contextual actions, settings groups); a plain Button is correct when there is exactly one canonical action.

  • link

    `Link` navigates to a URL; `MenuButton` opens an in-page menu of commands that do not change the URL. A common confusion is a "user menu" that mixes both — canon: render the trigger as a `MenuButton`, render the navigation entries inside as `Link` children.

  • menu

    `MenuButton` combines a trigger button with a menu — the button activates the menu, the menu hosts the commands. `Menu` is the menu surface alone, composable independently of any specific trigger (right-click context menus, embedded action lists, sub-menus of a parent menu). MenuButton internally renders a Menu; Menu canonicalises the list-of-commands pattern for the cases where the trigger is not a button (right- click contextmenu, embedded permanent menu, programmatic opening from a non-button trigger).

Menu Button pairs a trigger with a popup menu of items — the canonical pattern for action menus, overflow menus, and context-sensitive command lists. It implements the APG menu-button role with aria-haspopup and aria-expanded on the trigger, role=menu on the popup, and roving tabindex over menu items. The reference covers the keyboard contract that distinguishes activation from navigation, the divergence from Combobox (filtered text input) and Select (form-bound value), and the dismiss-reason vocabulary shared with Popover.

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.

standard icon-only

Properties

The same component, parameterised.

PropertyType
hasCaret boolean
side top | right | bottom | left
align start | center | end
size sm | 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

Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe: /api/components/menu-button/a11y-fixture.json

Both

Contracts

Non-negotiable contracts

  1. APGAPG: Menu Button pattern

    `role="menu"` hosts a list of commands invoked on activation and uses the APG menu-keyboard contract (ArrowKeys for item navigation, Enter / Space to invoke, Escape / Tab to close). Form controls and arbitrary interactive content do not belong inside `role="menu"`.

    Menus containing inputs or arbitrary interactive content violate the role contract — SR users cannot Tab into the items, and keyboard navigation breaks on both sides. Reach for Popover (`role="dialog"`) when the surface needs to host arbitrary content.

Dev

Common mistakes

Blocker

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

Blocker

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

Blocker

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

Major

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

Minor

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

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.