A list of actions and settings that opens from a button, such as the top bar's menu of kiosk mode, help and the map's display settings, moved through with the arrow keys and closed with Esc, a choice or a press outside it.
When to use
Use Menu for a short list of actions and settings that opens from one button, such as the top bar's menu of kiosk mode, help and the map's display settings.
| Use instead | When |
|---|---|
| Select | The list picks a form's value from a short, fixed list |
| SearchField | The list is long and is searched by typing |
| Popover | The popup holds an explanation or a few settings, not a list of actions |
| Toolbar | The actions fit on the screen, side by side in one bar |
Kongens Nytorv
Metro M1, M2 and M3.
Variants and states
| Rest | Disabled | |
|---|---|---|
| Default |
- Rest
- Disabled
Menu styles hover, pressed and focus on its inner parts, which a still preview can't reach, so this table shows rest and disabled only.
Props
| Name | Type | Default | Description |
|---|---|---|---|
trigger* | ReactElement<unknown, JSXElementConstructor<any> | string> | — | The button that opens the menu, such as a Button or an IconButton, rendered in place of the menu. Required: it opens the menu, names it, and gets focus back when the menu closes. |
children | ReactNode | — | The menu's items, in reading order: MenuItem, MenuLinkItem, MenuCheckboxItem, MenuRadioGroup, MenuGroup, MenuSeparator and MenuSubmenu. |
open | boolean | — | Whether the menu is open. Set it to control the menu, with onOpen and onClose to hear when it asks to change; leave it out for a menu its trigger opens. |
defaultOpen | boolean | false | Whether the menu is open at first, when open is left out. |
onOpen | (event: Event, details: MenuRootChangeEventDetails) => void | — | Called when the menu asks to open, from its trigger: details.reason is 'trigger-press', 'trigger-hover' or another of Base UI's reasons, and details.cancel() keeps it closed. |
onClose | (event: Event, reason: Menu.Root.ChangeEventReason, details: Menu.Root.ChangeEventDetails) => void | — | Called when the menu asks to close: reason is 'item-press', 'escape-key', 'outside-press', 'focus-out' or another of Base UI's reasons, and details.cancel() keeps it open. |
disabled | boolean | false | Whether the trigger ignores the pointer and the keys, so the menu can't open. The trigger carries data-disabled. |
id | string | — | The popup's id, which the trigger's aria-controls reads. Base UI sets one itself when left out. |
className | string | — | Reach the root, the popup. className is merged after pn-Menu. |
style | CSSProperties | — | Reach the root, the popup. className is merged after pn-Menu. |
ref | Ref<HTMLDivElement> | — | Reach the root, the popup. className is merged after pn-Menu. |
slotProps | { trigger?: Props<unknown> | undefined; portal?: MenuPortalProps | undefined; positioner?: MenuPositionerProps | undefined; } | — | Props for each part: trigger, portal, positioner. |
trigger (required)ReactElement<unknown, JSXElementConstructor<any> | string>, default —- The button that opens the menu, such as a Button or an IconButton, rendered in place of the menu. Required: it opens the menu, names it, and gets focus back when the menu closes.
childrenReactNode, default —- The menu's items, in reading order:
MenuItem,MenuLinkItem,MenuCheckboxItem,MenuRadioGroup,MenuGroup,MenuSeparatorandMenuSubmenu. openboolean, default —- Whether the menu is open. Set it to control the menu, with
onOpenandonCloseto hear when it asks to change; leave it out for a menu its trigger opens. defaultOpenboolean, defaultfalse- Whether the menu is open at first, when
openis left out. onOpen(event: Event, details: MenuRootChangeEventDetails) => void, default —- Called when the menu asks to open, from its trigger:
details.reasonis'trigger-press','trigger-hover'or another of Base UI's reasons, anddetails.cancel()keeps it closed. onClose(event: Event, reason: Menu.Root.ChangeEventReason, details: Menu.Root.ChangeEventDetails) => void, default —- Called when the menu asks to close:
reasonis'item-press','escape-key','outside-press','focus-out'or another of Base UI's reasons, anddetails.cancel()keeps it open. disabledboolean, defaultfalse- Whether the trigger ignores the pointer and the keys, so the menu can't open. The trigger carries
data-disabled. idstring, default —- The popup's
id, which the trigger'saria-controlsreads. Base UI sets one itself when left out. classNamestring, default —- Reach the root, the popup.
classNameis merged afterpn-Menu. styleCSSProperties, default —- Reach the root, the popup.
classNameis merged afterpn-Menu. refRef<HTMLDivElement>, default —- Reach the root, the popup.
classNameis merged afterpn-Menu. slotProps{ trigger?: Props<unknown> | undefined; portal?: MenuPortalProps | undefined; positioner?: MenuPositionerProps | undefined; }, default —- Props for each part:
trigger,portal,positioner.
Use it
import { Menu } from '@mylder/perron'Ask your agent
get_reference({ library: "base-ui", component: "Menu" })Keyboard and ARIA
From the Base UI reference for Menu and the installed MenuRoot, MenuTrigger, MenuPopup, useMenuItemCommonProps, MenuCheckboxItem, MenuRadioItem, MenuGroupLabel, MenuRadioGroup, MenuSubmenuTrigger and the list navigation they build on. "From" says who does it.
| Key | Does | From |
|---|---|---|
Enter, Space, ArrowDown on the trigger | Open the menu, calling onOpen with reason 'trigger-press' or 'list-navigation', and move focus to the first item | The browser activates the button; Base UI opens and moves focus |
ArrowUp on the trigger | Opens the menu and moves focus to the last item | Base UI |
ArrowDown, ArrowUp | Move focus to the next and the previous item, disabled items included, skipping group labels and separators, and wrap from the last to the first and back | Base UI |
Home, End | Move focus to the first and the last item | Base UI |
| A letter or letters | Move focus to the next item whose text, or label, starts with them | Base UI |
Enter, Space on an item | Run its onClick and close the menu, calling onClose with reason 'item-press', and return focus to the trigger; on a disabled item, nothing | Base UI |
Enter, Space on a link item | Follow the link | The browser; Base UI for Space |
Enter, Space on a checkbox item | Turn it on or off, calling onChange with the new checked, and keep the menu open | Base UI |
Enter, Space on a radio item | Choose it, calling the group's onChange with its value, and keep the menu open | Base UI |
ArrowRight, Enter, Space on a submenu's row | Open the submenu and move focus to its first item | Base UI |
ArrowLeft in a submenu | Closes the submenu and returns focus to its row | Base UI |
Escape | Closes the innermost open menu, a submenu first, returning focus to its row or to the trigger; closing the menu calls onClose with reason 'escape-key' | Base UI |
Tab, Shift+Tab | Move focus out of the menu, on past the trigger or back to it, and close the menu, calling onClose with reason 'focus-out' | Base UI |
A press outside the popup closes the menu too, with reason 'outside-press', and focus returns to the trigger.
| Element | Role | Accessible name from | States announced |
|---|---|---|---|
trigger | button, native, or the caller's element's own | Its own content, from the caller | aria-haspopup="menu", aria-expanded, from Base UI; disabled |
root | menu, from Base UI | aria-labelledby the trigger, or the caller's aria-label | none |
item | menuitem, from Base UI | Its text, the caller's children | aria-disabled while disabled |
linkItem | menuitem, on a native <a href>, from Base UI | Its text, the caller's children | none |
checkboxItem | menuitemcheckbox, from Base UI | Its text, the caller's children | aria-checked; aria-disabled while disabled |
radioGroup | group, from Base UI | aria-labelledby its groupLabel, the caller's label | aria-disabled while disabled |
radioItem | menuitemradio, from Base UI | Its text, the caller's children | aria-checked; aria-disabled while disabled |
group | group, from Base UI | aria-labelledby its groupLabel, the caller's label | none |
groupLabel | none: aria-hidden="true", from Base UI, read as the group's name instead | none | none |
separator | separator, from Base UI | none | aria-orientation="horizontal" |
submenu | menuitem with aria-haspopup="menu", from Base UI | Its text, the caller's label | aria-expanded while closed (Base UI drops it while open, for VoiceOver); aria-disabled while disabled |
submenuPopup | menu, from Base UI | aria-labelledby its row | none |
startIcon, chevron, both indicators | none: aria-hidden="true" | none | none |
What axe won't catch, from the contract, each with how this component meets it:
- Everything the eye is told, the ear is told, for every shape each prop accepts: each row is named by its label; the start icon, the chevron, the box and the tick are
aria-hidden, and what they show is announced by the row's role and state:menuitemcheckboxwitharia-checkedfor the box,menuitemradiowitharia-checkedfor the tick,aria-haspopup="menu"for the chevron; a group's label is its name; disabled isaria-disabled. - Every landmark and named region has an accessible name: the menu is named by its trigger and a submenu by its row, through
aria-labelledby, and every group with a label by it; the stories give every group a label but theSubmenustory's radio group, whose choice the submenu's row, "Language", already names, asMenuRadioGroup'slabelrow allows. - Every default label can be overridden: none here; Menu owns no strings.
- Every shape the type accepts renders, in every variant:
trigger,children,labelandstartIconrender a string, an element and a list alike; a long word in a label wraps (CR-11). - A hidden copy kept for animation is
aria-hidden, and a collapsed region isinert: none here; the closed menu renders nothing, and the unchecked box is a mark inside its row,aria-hiddenlike the checked one. - Native semantics first: a native
<button>trigger and<a href>link items, and Base UI'smenu,menuitem,menuitemcheckbox,menuitemradio,groupandseparatorroles, with no ARIA of Perron's own. - Focus moves in reading order and returns on close: the arrow keys move through the items in document order, the order on screen; opening from the keys focuses the first item; closing, by any reason, returns focus to the trigger, also for a menu open at first render (CR-5), and closing a submenu returns it to the submenu's row.
- Every target is at least
size.target-minsquare: the smallest target is a row, at leastsize.target-mintall and as wide as the popup. - Loops stop when the system asks for less motion: none here; the menu has no loop and no transition (CR-12).
Owned strings
No owned strings: every word comes from the caller, as the trigger's content, each row's children or label, and each group's label. The box, the tick and the chevron say nothing of their own: the rows' roles and states announce them.
Do and don't
For a caller using the component, with the reason. Each row is a rule: a must row's Don't is never right, a should row's needs a reason, and Checked by says what catches the Don't in a caller's code.
| Rule | Level | Do | Don't | Checked by |
|---|---|---|---|---|
| DD-1 | must | Pass the button that opens the menu as trigger, with words that name the menu, such as "Map options", or an IconButton with an aria-label | Open a menu from a button placed beside it with open alone: the button loses aria-haspopup and aria-expanded, and the menu its name | types |
| DD-2 | must | Set slotProps.trigger.nativeButton to false when trigger is not a <button>, as Base UI warns | Pass a link as the trigger: a link goes somewhere, and a menu opens over the screen | review |
| DD-3 | must | Disable the menu with disabled on the Menu | Disable only the trigger element: Base UI's trigger stays enabled and the menu still opens | review |
| DD-4 | must | Put actions in MenuItems, links in MenuLinkItems, settings in MenuCheckboxItems and one choice in a MenuRadioGroup with a label | Put a Button, a Switch or a plain <a> in the popup: it falls out of the arrow-key order and gets no menu role | review |
| DD-5 | must | Keep a checkbox item's words the same on and off, such as "Show stop names", and let its box and aria-checked say the state | Change a setting's words when it turns on, or use a checkbox item for an action | review |
| DD-6 | must | Name each group with label, in sentence case, and divide groups with MenuSeparator | Put a heading or a styled <div> in the popup as a label: it isn't tied to its group, and the arrow keys would skip a group they can't name | review |
| DD-7 | should | Give every row in a menu a start icon, drawn for icon.size.400, or give none of them one; a checkbox or radio item takes none, so a menu that holds one gives its other rows none | Give some rows an icon and leave others without, or use an icon drawn for icon.size.450: the labels start at two edges, and a scaled-down icon's stroke falls under 1.5 px | review |
| DD-8 | should | Keep a disabled item in place, so the person learns it exists, and keep menus short, a submenu one level deep at most | Hide and show items as the state changes, or nest submenus: rows that move and deep paths are hard to follow on a phone | review |
| DD-9 | must | Use a menu for a short list of actions and settings that open from one button | Use a menu to choose a form's value or to search a long list: that is a Select or a SearchField, which submit and filter | review |
| DD-10 | must | Open a menu from a toolbar by passing a ToolbarButton as trigger, so it joins the toolbar's arrow-key order | Put a Menu's trigger beside a toolbar's buttons as a plain Button: it adds a tab stop and falls out of the toolbar's arrow keys | review |
| DD-11 | should | Let the menu take z-index.dialog, its place against the trigger and its glass | Set a z-index, a position or a background on the popup: it would fall under another overlay or leave the Popups family | review |