Menu
Pickers and action menus on the shared popup surface: search, groups, checks and submenus.
import { Menu, MenuTrigger, MenuContent, … } 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 with an ellipsis: 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.
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.
MenuContent takes where the menu sits and the menu knobs below; each row (MenuItem, MenuRadioItem, …) takes the row knobs, each a token of its kind. A colour, a raw length or any other property is a type error.
| Knob | Takes | Default | Sets |
|---|---|---|---|
TokenRef<Measure> | 180px | Narrowest width (180, 220 for lg); the trigger's width when wider. | |
TokenRef<Measure> | 320px | Widest width (320). | |
TokenRef<Measure> | 420px | Tallest height (420); the space left beside the trigger when less. | |
TokenRef<Radius> | var(--radius-xl) | Corner radius. | |
TokenRef<Space> | var(--space-1-5) | Padding around the rows (6, 8 for lg). | |
TokenRef<Space> | 0px | Gap between rows. | |
TokenRef<Control> | 30px | Row height (30, 32 for lg). | |
TokenRef<Space> | var(--space-1) | Row block padding. | |
TokenRef<Space> | var(--space-2-5) | Row inline padding on a text side: the content inset of a menu without icons, and every row's end. | |
TokenRef<Space> | Derived | stylex.env.nest.iconInset("var(--menu-item-h) | Row start padding in a menu whose rows lead with icons: the icon side, (row height − 16) / 2 (R2). | |
TokenRef<Space> | var(--space-2) | Gap between a row's icon, label and end. | |
TokenRef<Radius> | stylex.env.nest.radius | Row corner radius (the highlight follows it). | |
TokenRef<Space> | 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.
| Knob | Takes | Default | Sets |
|---|---|---|---|
Paint | var(--text-1) | Label colour. | |
Paint | var(--text-4) | Label colour when disabled. | |
Paint | var(--icon-2) | Leading icon colour (an element icon such as a provider mark reads it too). | |
Paint | var(--icon-1) | Leading icon colour while the row is highlighted or its submenu is open. | |
Paint | var(--icon-4) | Leading icon colour when disabled. | |
Paint | var(--fill-4) | The highlight's fill under the pointer (drawn by the menu's Glide). | |
Paint | 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–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–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–Z on a row
- Back to the field, the key included
- Esc
- Close the menu
Menu
open- boolean
- Controlled open state.
defaultOpen- booleanDefaultfalse
- 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- booleanDefaulttrue
- 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- numberDefault6
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. 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- booleanDefaulttrue
- 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">.
checked- boolean
- Controlled checked state.
defaultChecked- booleanDefaultfalse
- 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- booleanDefaulttrue
- 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: 12px sentence case, text-3), associated with the group for assistive tech.
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- booleanDefaultfalse
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- booleanDefaulttrue
- 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: 12px sentence case, text-3), associated with the group for assistive tech.
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- numberDefault0
- 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.
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- numberDefault6
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. 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.