Menu
Pickers and action menus on the shared popup surface: search, groups, checks and submenus.
import { Menu, MenuTrigger, MenuContent and 13 more } from "@arcadia/ui"Compose the trigger with render: a Button, an IconButton or a PickerTrigger. md is the picker menu (30px rows, 6px padding, full-bleed separators); lg is the action and context menu (32px rows, 8px padding, inset separators); both are radius 12. Section labels are eyebrows. The menu slides 4px toward its trigger as it opens, one highlight glides between its rows (a Glide), and submenus open on hover with no delay.
Icons are one column. Every leading glyph (a line icon, a provider mark, a swatch) sits in the same 16px slot, and once any row in a menu has an icon, the rows without one keep that column, so every label starts on one line. Labels never trail off in dots: the dialog a row opens, or a submenu's chevron, already says more follows.
Which overlay? A Menu lists actions or choices. A Popover holds content you act on, such as a form. A Hover Card previews what a link points to, and a Tooltip names a control in a few words.
Examples
Searchable picker
MenuSearch sits above the scroller and owns typing; the product owns filtering.
Its magnifier stands in the rows' icon column and the query starts on their labels. Each query lights its first match: Enter picks it, and ↓ moves focus onto it, never past it. A key typed on a row goes back to the field.
Opens on whole rows
A menu opens with its checked row in view and the top of its list on a row's top edge, the row above the checked one first, its top edge the list's padding under the field.
Scrolled under the search field, rows fade into it as far as they went, up to 8px: never sliced by its edge, and never so deep that a row's words dim.
Groups and toggles
Every row closes the menu when you pick it, checkbox rows too, as macOS menus do.
A set of toggles you set together, such as a filter, passes closeOnClick={false} on each row. One setting that modifies another row takes indicator="switch": a switch draws its state, so it never reads as a second pick beside a checked row.
States
Rendered open in place: rest, highlighted, a description, a shortcut, disabled, checked, a submenu trigger and danger, at both sizes. The specimen at the top shows a submenu open.
md: pickers, 30px rowslg: actions, 32px rowsTrailing content
end takes a dot, a badge or a short text after the label.
A detail end gives way first: in a menu too narrow for both it steps down by whole words, then goes whole, while the label keeps its words, and the row's tooltip says it all. An end that carries state (endYields={false}) stays: the label steps down by whole words first, never past the form that tells it from the other rows (apart). A row is named for where it leads, never for a state: a dot says something waits there, and the page it opens says what.
MenuContent takes where the menu sits and the menu knobs below; each row (MenuItem, MenuRadioItem and the rest) takes the row knobs, each a token of its kind. A colour, a raw length or any other property is a type error.
--menu-min-w- TokenRef<Measure>Default 180px
- Narrowest width (180, 220 for
lg); the trigger's width when wider. --menu-max-w- TokenRef<Measure>Default 320px
- Widest width (320).
--menu-max-h- TokenRef<Measure>Default 420px
- Tallest height (420); the space left beside the trigger when less.
--menu-radius- TokenRef<Radius>Default var(--radius-xl)
- Corner radius.
--menu-pad- TokenRef<Space>Default var(--space-1-5)
- Padding around the rows (6, 8 for
lg). --menu-gap- TokenRef<Space>Default 0px
- Gap between rows.
--menu-item-h- TokenRef<Control>Default 30px
- Row height (30, 32 for
lg). --menu-item-py- TokenRef<Space>Default var(--space-1)
- Row block padding.
--menu-item-px- TokenRef<Space>Default var(--space-2-5)
- Row inline padding on a text side: the content inset of a menu without icons, and every row's end.
--menu-item-px-icon- TokenRef<Space> | Derived
- Row start padding in a menu whose rows lead with icons: the icon side, (row height − 16) / 2 (R2).
--menu-item-gap- TokenRef<Space>Default var(--space-2)
- Gap between a row's icon, label and end.
--menu-item-radius- TokenRef<Radius>
- Row corner radius (the highlight follows it).
--menu-sep-block- TokenRef<Space>Default var(--space-0-75)
- Space above and below a separator.
Item corners follow --menu-radius minus --menu-pad; changing the frame or padding keeps the highlight concentric.
--menu-item-fg- PaintDefault var(--text-1)
- Label colour.
--menu-item-fg-disabled- PaintDefault var(--text-4)
- Label colour when disabled.
--menu-item-icon- PaintDefault var(--icon-2)
- Leading icon colour (an element icon such as a provider mark reads it too).
--menu-item-icon-hl- PaintDefault var(--icon-1)
- Leading icon colour while the row is highlighted or its submenu is open.
--menu-item-icon-disabled- PaintDefault var(--icon-4)
- Leading icon colour when disabled.
--menu-item-fill- PaintDefault var(--fill-4)
- The highlight's fill under the pointer (drawn by the menu's Glide).
--menu-item-fill-kbd- PaintDefault var(--fill-3)
- The highlight's fill while the keyboard drives it: a step stronger.
Each state reads its own knob (--menu-item-icon-hl, --menu-item-fill-kbd, --menu-item-fg-disabled), so a knob you set never drops a state. An element icon, such as a provider mark, takes the row's icon colour too.
The APG menu pattern: the trigger gets aria-haspopup and aria-expanded, focus moves into the menu and returns to the trigger on close. A row's shortcut is drawn only (aria-hidden, so the row reads "Rename", not "Rename F2") and exposed as aria-keyshortcuts.
- ↓↑
- Move the highlight
- EnterSpace
- Activate the highlighted row
- →
- Open a submenu
- ←
- Close a submenu
- Esc
- Close the menu
- A to Z
- Jump to a row by its first letters
With a search field
An input may not sit inside role="menu", so a searchable menu moves the roles: the popup is a group labelled by the trigger, the field is a combobox (aria-controls the menu, aria-activedescendant on the lit row, aria-autocomplete="list") and the scroller is the menu.
- A to Z
- Filter; the first match lights up
- Enter
- Pick the lit row
- ↓↑
- Move focus onto the lit row (the first press never skips it); then the menu's keys apply
- A to Z on a row
- Back to the field, the key included
- Esc
- Close the menu
Menu
open- boolean
- Controlled open state.
defaultOpen- booleanDefault false
- Initial open state when uncontrolled.
onOpenChange- (open: boolean, details: OpenChangeDetails) => void
- Called when the menu opens or closes, with why (
details.reason) and the event. onOpenChangeComplete- (open: boolean) => void
- Called once the open or close transition has finished.
modal- booleanDefault true
- Lock page scroll and pointer interaction outside while open.
render- RenderProp<{ disabled: boolean }>
- Replace the rendered element, e.g.
render={<a href="/settings" />}, or a function of props and state. disabled- boolean
- Native disabled: no pointer events, no tooltip, skipped by Tab.
focusableWhenDisabled- boolean
- Keep a disabled control focusable and hoverable (
aria-disabled), so a tooltip can say why. nativeButton- boolean
- Button semantics, inferred from what renders: a
<button>is native, an<a>stays a link, anything else getsrole="button"and key handling.falseforces button semantics on whatever renders (a link acting as a button).
Rest props go to <button> props.
rootStyle- MenuStyle
- Typed StyleX override: where the menu sits (layout) and its
--menu-*knobs. side- "top" | "bottom" | "left" | "right" | "inline-start" | "inline-end"Default "bottom"
align- "start" | "center" | "end"Default "start"
sideOffset- numberDefault 6
alignOffset- number
collisionPadding- number
size- "md" | "lg"Default "md"
mdpickers,lgaction and context menus.aria-label- string
- The menu's name where its trigger doesn't give one: a context menu ("Chat actions"; default "Context menu"), or with
search, what the list under the field holds ("Models", "Projects"). width- "auto" | "trigger" | number
auto(content, 180–320),trigger(exactly the trigger's width) or a pixel width.search- ReactNode
- A
<MenuSearch/>row, rendered above the scroller so it never scrolls away. footer- ReactNode
- A band under the rows, never scrolled.
instant- boolean
- Skip the open/close transition (re-anchoring from one picker to the next).
finalFocus- FocusTarget
- Where focus goes on close. Default: the trigger.
className- string
- A product's hook class for its own rules (being retired: new code sets
rootStyle).
Rest props go to <div> props.
tone- "neutral" | "danger"Default "neutral"
dangertints label, icon and highlight red (destructive actions).onClick- (e: MouseEvent<HTMLElement>) => void
- Runs the action.
disabled- boolean
- Skipped by the keyboard and the pointer, drawn at text-4.
closeOnClick- booleanDefault true
- Close the menu after the action (rows and radios: true; checkboxes: false).
label- string
- The text typeahead matches (default: the row's text).
Rest props go to <div> props and Pick<CollapsibleHeaderProps, "action" | "details" | "icon" | "end" | "tone" | "loading" | "title" | "className">.
indicator- "check" | "switch"Default "check"
- The state at the row's end.
check: one of a set picked together.switch: one setting that modifies another row (the model's long context). The row stays the one control. checked- boolean
- Controlled checked state.
defaultChecked- booleanDefault false
- Initial checked state when uncontrolled.
onCheckedChange- (checked: boolean, details: { event: Event }) => void
- Called with the new state when the row is pressed.
onClick- (e: MouseEvent<HTMLElement>) => void
- Runs the action.
disabled- boolean
- Skipped by the keyboard and the pointer, drawn at text-4.
closeOnClick- booleanDefault true
- Close the menu after the action (rows and radios: true; checkboxes: false).
label- string
- The text typeahead matches (default: the row's text).
Rest props go to <div> props and Pick<CollapsibleHeaderProps, "action" | "details" | "icon" | "end" | "tone" | "loading" | "title" | "className">.
label- ReactNode
- Section label (an eyebrow), the group's accessible name.
value- unknown
- The checked row's value (controlled).
defaultValue- unknown
onValueChange- (value: any, details: { event: Event }) => void
- Called with the picked row's value.
disabled- booleanDefault false
Rest props go to <div> props.
valuerequired- unknown
- This choice's value.
onClick- (e: MouseEvent<HTMLElement>) => void
- Runs the action.
disabled- boolean
- Skipped by the keyboard and the pointer, drawn at text-4.
closeOnClick- booleanDefault true
- Close the menu after the action (rows and radios: true; checkboxes: false).
label- string
- The text typeahead matches (default: the row's text).
Rest props go to <div> props and Pick<CollapsibleHeaderProps, "action" | "details" | "icon" | "end" | "tone" | "loading" | "title" | "className">.
label- ReactNode
- Section label (an eyebrow), the group's accessible name.
Rest props go to <div> props.
autoFocus- boolean
- Focus the field when the menu opens.
className- string
- Class for the row (the input itself is
.ui-menu-search > input).
Rest props go to <input> props.
delay- numberDefault 0
- Hover open delay in ms.
onClick- (e: MouseEvent<HTMLElement>) => void
- Runs the action.
disabled- boolean
- Skipped by the keyboard and the pointer, drawn at text-4.
closeOnClick- booleanDefault false
- Close the menu after the action (rows and radios: true; checkboxes: false).
label- string
- The text typeahead matches (default: the row's text).
Rest props go to <div> props and Pick<CollapsibleHeaderProps, "action" | "details" | "icon" | "end" | "tone" | "loading" | "title" | "className">.
rootStyle- MenuStyle
- Typed StyleX override: where the menu sits (layout) and its
--menu-*knobs. sideOffset- numberDefault 6
alignOffset- number
collisionPadding- number
size- "md" | "lg"Default "md"
mdpickers,lgaction and context menus.aria-label- string
- The menu's name where its trigger doesn't give one: a context menu ("Chat actions"; default "Context menu"), or with
search, what the list under the field holds ("Models", "Projects"). width- "auto" | "trigger" | number
auto(content, 180–320),trigger(exactly the trigger's width) or a pixel width.search- ReactNode
- A
<MenuSearch/>row, rendered above the scroller so it never scrolls away. footer- ReactNode
- A band under the rows, never scrolled.
instant- boolean
- Skip the open/close transition (re-anchoring from one picker to the next).
finalFocus- FocusTarget
- Where focus goes on close. Default: the trigger.
className- string
- A product's hook class for its own rules (being retired: new code sets
rootStyle).
Rest props go to <div> props.