Menu
When to use
Section titled “When to use”- A “More actions ⋯” button on a row or a card
- A menu with submenus, such as File → Export as. For File, Edit and View side by side, use a Menubar
- Settings that toggle or pick one of several, such as a View menu
Not to pick a value for a form — use a Select or a Combobox, which submit with the form.
Choosing between messages and popups:
| Component | Use it for | For example |
|---|---|---|
| Dialog | Something that needs a decision or input before the user carries on; the page is blocked meanwhile | Confirm a delete, edit a record in a form |
| Drawer | A panel that slides in from an edge and blocks the page; the first choice on a phone | Share options, a mobile nav, a filter panel |
| Popover | Extra content or controls next to a button, while the rest of the page stays usable | Filter panel, share options, details card |
| Menu | A list of actions or settings opened from a button | “More actions ⋯”, a View menu |
| Tooltip | A short text label on hover or focus; nothing in it can be clicked | Name of an icon button, a keyboard shortcut |
| HoverCard | A preview of where a link goes, on hover or focus; nothing essential in it | A profile behind an @mention, a repository’s summary |
| Toast | A brief confirmation after an action, which goes away by itself | “Saved”, “Archived — Undo” |
| Alert | A message that stays in the page until the situation changes | “Your trial ends in 3 days”, a form error summary |
Menu opens a list of actions next to the button that opened it. It follows the
WAI-ARIA menu button pattern, and is a
native popover anchored like Popover: Esc, a click outside and focus
returning to the button are the platform’s.
import { useState } from 'react';import { Button, Menu } from '@arun-dev/ui';
export default function MenuBasics() { const [last, setLast] = useState('nothing yet');
return ( <div style={{ display: 'flex', gap: 'var(--space-sm)', alignItems: 'center' }}> <Menu.Root> {/* Trigger is a plain button: render a Button to style it. */} <Menu.Trigger render={<Button />}>Actions</Menu.Trigger> <Menu.Popup> <Menu.Item onClick={() => setLast('Edit')}>Edit</Menu.Item> <Menu.Item onClick={() => setLast('Duplicate')}>Duplicate</Menu.Item> <Menu.Item disabled>Archive</Menu.Item> <Menu.Item onClick={() => setLast('Delete')}>Delete</Menu.Item> </Menu.Popup> </Menu.Root> <span>Chose: {last}</span> </div> );}Anatomy
Section titled “Anatomy”import { Menu } from '@arun-dev/ui';
<Menu.Root> <Menu.Trigger /> <Menu.Popup> <Menu.Item /> <Menu.CheckboxItem> <Menu.ItemIndicator /> </Menu.CheckboxItem> <Menu.Group> <Menu.GroupLabel /> <Menu.Item /> </Menu.Group> <Menu.RadioGroup> <Menu.GroupLabel /> <Menu.RadioItem> <Menu.ItemIndicator /> </Menu.RadioItem> </Menu.RadioGroup> <Menu.SubmenuRoot> <Menu.SubmenuTrigger /> <Menu.Popup /> </Menu.SubmenuRoot> </Menu.Popup></Menu.Root>;| Part | Renders | Does |
|---|---|---|
Root | nothing | holds open, and the setter every other part goes through |
Trigger | <button> | opens it; the element it is anchored to, and its label |
Popup | <div> | the list: popover="auto", role="menu" |
Item | <button role="menuitem"> | one action; activating it closes the menu |
CheckboxItem | <button role="menuitemcheckbox"> | a setting that is on or off |
RadioGroup | <div role="group"> | holds the value of its RadioItems |
RadioItem | <button role="menuitemradio"> | one choice in a RadioGroup |
ItemIndicator | <span> | the check in a CheckboxItem or RadioItem |
Group | <div role="group"> | items under a label |
GroupLabel | <div> | names its Group or RadioGroup |
SubmenuRoot | nothing | holds a submenu’s open, as Root does |
SubmenuTrigger | <button role="menuitem"> | an item of the parent menu that opens the submenu |
Trigger is an unstyled button, so render a Button to style it.
Keyboard
Section titled “Keyboard”| Key | Does |
|---|---|
| Enter, Space, ↓ on Trigger | opens the menu at the first item |
| ↑ on Trigger | opens the menu at the last item |
| ↓ ↑ | the next or previous item, wrapping at the ends |
| Home, End | the first or last item |
| A letter or word | the next item whose text starts with what you typed |
| Enter, Space on an item | activates it and closes the menu |
| Enter, Space on a checkable item | checks it; the menu stays open |
| Enter, Space, → on a SubmenuTrigger | opens the submenu at its first item |
| ← in a submenu | closes it, focus back on its SubmenuTrigger |
| Esc | closes the menu, focus back on the Trigger; in a submenu, closes only that level |
| Tab | closes every level, and focus moves on past the Trigger |
Put the action in the Item’s onClick. Activating an item closes the menu; to keep it open —
a toggle, say — call event.preventComponentHandler() in your onClick.
Disabled items are skipped by the arrow keys. Set focusableWhenDisabled on the Root to keep them
in the sequence, so a screen reader announces them as unavailable. They still can’t be activated:
the item is aria-disabled instead of disabled, and your onClick isn’t called.
Typeahead
Section titled “Typeahead”Typing moves focus to the next item whose text starts with what you typed, ignoring case and
accents. Type a letter again to cycle through the items that start with it. Keys typed close
together build one search, so new f reaches “New folder”: a Space in the middle of a search is
part of it, not a click.
An item’s text comes from its children. If a component of yours renders the text, typeahead
can’t see it, so give the item a textValue.
Checkbox and radio items
Section titled “Checkbox and radio items”import { useState } from 'react';import { Button, Menu } from '@arun-dev/ui';
export default function MenuViewOptions() { const [grid, setGrid] = useState(true); const [rulers, setRulers] = useState(false); const [sort, setSort] = useState<string | null>('name');
return ( <div style={{ display: 'flex', gap: 'var(--space-sm)', alignItems: 'center' }}> <Menu.Root> <Menu.Trigger render={<Button />}>View</Menu.Trigger> <Menu.Popup> <Menu.Group> <Menu.GroupLabel>Show</Menu.GroupLabel> <Menu.CheckboxItem checked={grid} onCheckedChange={setGrid}> <Menu.ItemIndicator /> Grid </Menu.CheckboxItem> <Menu.CheckboxItem checked={rulers} onCheckedChange={setRulers}> <Menu.ItemIndicator /> Rulers </Menu.CheckboxItem> </Menu.Group> <Menu.RadioGroup value={sort} onValueChange={setSort}> <Menu.GroupLabel>Sort by</Menu.GroupLabel> <Menu.RadioItem value="name"> <Menu.ItemIndicator /> Name </Menu.RadioItem> <Menu.RadioItem value="date"> <Menu.ItemIndicator /> Date modified </Menu.RadioItem> <Menu.RadioItem value="size"> <Menu.ItemIndicator /> Size </Menu.RadioItem> </Menu.RadioGroup> </Menu.Popup> </Menu.Root> <span> Grid {grid ? 'on' : 'off'}, rulers {rulers ? 'on' : 'off'}, sorted by {sort} </span> </div> );}A CheckboxItem holds its own checked, controlled or not. A RadioGroup holds one value, and
each RadioItem inside is checked while that value is its own. Both leave the menu open when
activated, so several settings can be changed in one go; set closeOnClick to close it.
Put a Menu.ItemIndicator where the check should go. It stays in the layout when unchecked, so
labels line up. The styled one draws a checkmark; pass children to draw your own, such as a dot
for radio items.
Group and RadioGroup are named by a GroupLabel inside them. The arrow keys and typeahead run
through every group as one list, and skip the labels.
Submenus
Section titled “Submenus”import { useState } from 'react';import { Button, Menu } from '@arun-dev/ui';
export default function MenuSubmenus() { const [last, setLast] = useState('nothing yet');
return ( <div style={{ display: 'flex', gap: 'var(--space-sm)', alignItems: 'center' }}> <Menu.Root> <Menu.Trigger render={<Button />}>File</Menu.Trigger> <Menu.Popup> <Menu.Item onClick={() => setLast('New')}>New</Menu.Item> <Menu.Item onClick={() => setLast('Open')}>Open</Menu.Item> <Menu.SubmenuRoot> <Menu.SubmenuTrigger>Share</Menu.SubmenuTrigger> <Menu.Popup> <Menu.Item onClick={() => setLast('Email')}>Email</Menu.Item> <Menu.Item onClick={() => setLast('Copy link')}>Copy link</Menu.Item> <Menu.SubmenuRoot> <Menu.SubmenuTrigger>Export as</Menu.SubmenuTrigger> <Menu.Popup> <Menu.Item onClick={() => setLast('PDF')}>PDF</Menu.Item> <Menu.Item onClick={() => setLast('PNG')}>PNG</Menu.Item> </Menu.Popup> </Menu.SubmenuRoot> </Menu.Popup> </Menu.SubmenuRoot> <Menu.Item onClick={() => setLast('Print')}>Print</Menu.Item> </Menu.Popup> </Menu.Root> <span>Chose: {last}</span> </div> );}Wrap a SubmenuTrigger and a Popup in a SubmenuRoot. The trigger is an item of the parent
menu, so the parent’s arrow keys and typeahead reach it, and the submenu opens beside it, on the
right by default. Submenus nest as deep as you need.
- Keyboard: Enter, Space or → opens the submenu at its first item, and ← closes it. In a
right-to-left menu the arrows swap, and you pass
side="left"to the submenu’sPopup. - Pointer: resting on the trigger opens the submenu without moving focus. Moving to another item of the parent closes it, but not while the pointer is crossing toward the submenu, even over other items on the way.
- Closing: Esc and a click elsewhere in the parent close one level. Activating an item, or Tab, closes every level.
Menu.Root
Section titled “Menu.Root”| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | Controlled state. Provide onOpenChange alongside it. Never pass undefined — the mode is fixed at mount. | |
defaultOpen | boolean | false | Initial state when uncontrolled. Read once, at mount. |
onOpenChange | (open: boolean) => void | Called on every request to open or close: Trigger, an Item, Tab, Esc or a click outside. A controlled menu moves only if you accept it. | |
focusableWhenDisabled | boolean | false | Keeps disabled items in the arrow-key sequence, so a screen reader announces them as unavailable. They still cannot be activated. |
children | ReactNode | The other parts. Root renders no element of its own. |
Menu.Popup
Section titled “Menu.Popup”| Prop | Type | Default | Description |
|---|---|---|---|
side | 'top' | 'bottom' | 'left' | 'right' | 'bottom'; 'right' in a submenu | Which side of the Trigger to open on. Flips to the opposite side when there is no room. |
align | 'start' | 'center' | 'end' | 'start' | Flush with the Trigger's start or end edge, or centred on it. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Menu.Item
Section titled “Menu.Item”| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Cannot be activated. Skipped by the arrow keys and typeahead, unless the Root sets focusableWhenDisabled. |
textValue | string | The text typeahead matches. Defaults to the text in children; set it when a component of yours renders the text. | |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Menu.CheckboxItem
Section titled “Menu.CheckboxItem”| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | Controlled state. Provide onCheckedChange alongside it. Never pass undefined — the mode is fixed at mount. | |
defaultChecked | boolean | false | Initial state when uncontrolled. Read once, at mount. |
onCheckedChange | (checked: boolean) => void | Called with the new state, in both controlled and uncontrolled modes. | |
closeOnClick | boolean | false | Closes the menu when activated. Off by default, so several settings can be changed at once. |
disabled | boolean | false | Cannot be activated. Skipped by the arrow keys and typeahead, unless the Root sets focusableWhenDisabled. |
textValue | string | The text typeahead matches. Defaults to the text in children; set it when a component of yours renders the text. | |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Menu.RadioGroup
Section titled “Menu.RadioGroup”| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | Controlled value: the value of the checked RadioItem, or null for none. Provide onValueChange alongside it. | |
defaultValue | string | null | null | Initial value when uncontrolled. Read once, at mount. |
onValueChange | (value: string) => void | Called with the value of the RadioItem being checked. | |
disabled | boolean | false | Disables every RadioItem inside. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Menu.RadioItem
Section titled “Menu.RadioItem”| Prop | Type | Default | Description |
|---|---|---|---|
value | string | Required. Checked while the RadioGroup's value equals it. | |
closeOnClick | boolean | false | Closes the menu when activated. Off by default, so several settings can be changed at once. |
disabled | boolean | false | Cannot be activated. Skipped by the arrow keys and typeahead, unless the Root sets focusableWhenDisabled. |
textValue | string | The text typeahead matches. Defaults to the text in children; set it when a component of yours renders the text. | |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Menu.SubmenuRoot
Section titled “Menu.SubmenuRoot”| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | Controlled state. Provide onOpenChange alongside it. Never pass undefined — the mode is fixed at mount. | |
defaultOpen | boolean | false | Initial state when uncontrolled. Read once, at mount. |
onOpenChange | (open: boolean) => void | Called on every request to open or close: its SubmenuTrigger, the arrow keys, an Item, Esc, a click elsewhere, or the pointer moving to another item. | |
focusableWhenDisabled | boolean | As on Menu.Root. Defaults to the parent menu's setting. | |
children | ReactNode | A SubmenuTrigger and a Popup. Renders no element of its own. |
Menu.SubmenuTrigger
Section titled “Menu.SubmenuTrigger”| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Cannot open its submenu. Skipped by the arrow keys and typeahead, unless the Root sets focusableWhenDisabled. |
textValue | string | The text typeahead matches in the parent menu. Defaults to the text in children. | |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Menu.Trigger takes the props of the <button> it renders, plus render and ref.
Menu.Group, Menu.GroupLabel and Menu.ItemIndicator take the props of the element they
render, too.
- The popup is named by the Trigger through
aria-labelledby. If you give the Trigger your ownid, name the popup yourself witharia-label. - Restyle every menu with the
--menu-*tokens, e.g.--menu-min-width. - Types come from the package that owns them:
import type { MenuItemProps } from '@arun-dev/headless/menu', orComponentProps<typeof Menu.Item>.