Skip to content

Menu

  • 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:

ComponentUse it forFor example
DialogSomething that needs a decision or input before the user carries on; the page is blocked meanwhileConfirm a delete, edit a record in a form
DrawerA panel that slides in from an edge and blocks the page; the first choice on a phoneShare options, a mobile nav, a filter panel
PopoverExtra content or controls next to a button, while the rest of the page stays usableFilter panel, share options, details card
MenuA list of actions or settings opened from a button“More actions ⋯”, a View menu
TooltipA short text label on hover or focus; nothing in it can be clickedName of an icon button, a keyboard shortcut
HoverCardA preview of where a link goes, on hover or focus; nothing essential in itA profile behind an @mention, a repository’s summary
ToastA brief confirmation after an action, which goes away by itself“Saved”, “Archived — Undo”
AlertA 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.

Chose: nothing yet
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>
);
}
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>;
PartRendersDoes
Rootnothingholds 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
SubmenuRootnothingholds 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.

KeyDoes
Enter, Space, ↓ on Triggeropens the menu at the first item
↑ on Triggeropens the menu at the last item
↓ ↑the next or previous item, wrapping at the ends
Home, Endthe first or last item
A letter or wordthe next item whose text starts with what you typed
Enter, Space on an itemactivates it and closes the menu
Enter, Space on a checkable itemchecks it; the menu stays open
Enter, Space, → on a SubmenuTriggeropens the submenu at its first item
← in a submenucloses it, focus back on its SubmenuTrigger
Esccloses the menu, focus back on the Trigger; in a submenu, closes only that level
Tabcloses 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.

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.

Grid on, rulers off, sorted by name
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.

Chose: nothing yet
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’s Popup.
  • 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 props
PropTypeDefaultDescription
openbooleanControlled state. Provide onOpenChange alongside it. Never pass undefined — the mode is fixed at mount.
defaultOpenbooleanfalseInitial state when uncontrolled. Read once, at mount.
onOpenChange(open: boolean) => voidCalled 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.
focusableWhenDisabledbooleanfalseKeeps disabled items in the arrow-key sequence, so a screen reader announces them as unavailable. They still cannot be activated.
childrenReactNodeThe other parts. Root renders no element of its own.
Menu.Popup props
PropTypeDefaultDescription
side'top' | 'bottom' | 'left' | 'right''bottom'; 'right' in a submenuWhich 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.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Menu.Item props
PropTypeDefaultDescription
disabledbooleanfalseCannot be activated. Skipped by the arrow keys and typeahead, unless the Root sets focusableWhenDisabled.
textValuestringThe text typeahead matches. Defaults to the text in children; set it when a component of yours renders the text.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Menu.CheckboxItem props
PropTypeDefaultDescription
checkedbooleanControlled state. Provide onCheckedChange alongside it. Never pass undefined — the mode is fixed at mount.
defaultCheckedbooleanfalseInitial state when uncontrolled. Read once, at mount.
onCheckedChange(checked: boolean) => voidCalled with the new state, in both controlled and uncontrolled modes.
closeOnClickbooleanfalseCloses the menu when activated. Off by default, so several settings can be changed at once.
disabledbooleanfalseCannot be activated. Skipped by the arrow keys and typeahead, unless the Root sets focusableWhenDisabled.
textValuestringThe text typeahead matches. Defaults to the text in children; set it when a component of yours renders the text.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Menu.RadioGroup props
PropTypeDefaultDescription
valuestring | nullControlled value: the value of the checked RadioItem, or null for none. Provide onValueChange alongside it.
defaultValuestring | nullnullInitial value when uncontrolled. Read once, at mount.
onValueChange(value: string) => voidCalled with the value of the RadioItem being checked.
disabledbooleanfalseDisables every RadioItem inside.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Menu.RadioItem props
PropTypeDefaultDescription
valuestringRequired. Checked while the RadioGroup's value equals it.
closeOnClickbooleanfalseCloses the menu when activated. Off by default, so several settings can be changed at once.
disabledbooleanfalseCannot be activated. Skipped by the arrow keys and typeahead, unless the Root sets focusableWhenDisabled.
textValuestringThe text typeahead matches. Defaults to the text in children; set it when a component of yours renders the text.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Menu.SubmenuRoot props
PropTypeDefaultDescription
openbooleanControlled state. Provide onOpenChange alongside it. Never pass undefined — the mode is fixed at mount.
defaultOpenbooleanfalseInitial state when uncontrolled. Read once, at mount.
onOpenChange(open: boolean) => voidCalled 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.
focusableWhenDisabledbooleanAs on Menu.Root. Defaults to the parent menu's setting.
childrenReactNodeA SubmenuTrigger and a Popup. Renders no element of its own.
Menu.SubmenuTrigger props
PropTypeDefaultDescription
disabledbooleanfalseCannot open its submenu. Skipped by the arrow keys and typeahead, unless the Root sets focusableWhenDisabled.
textValuestringThe text typeahead matches in the parent menu. Defaults to the text in children.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything 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 own id, name the popup yourself with aria-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', or ComponentProps<typeof Menu.Item>.