Button Menu
An accessible button trigger that opens a configurable contextual menu of actions.
Button Menu
ButtonMenu combines a Button trigger with a Menu popover. It supports controlled and uncontrolled open state, configurable placement and width, outside-click and Escape handling, selectable menu items, nested submenus, custom button props, and all standard menu item content.
Basic usage
import { ButtonMenu } from '@mosmox/ui';
<ButtonMenu
entries={[
{ key: 'edit', label: 'Edit' },
{ key: 'duplicate', label: 'Duplicate' },
{ type: 'divider' },
{ key: 'delete', label: 'Delete', danger: true },
]}
>
Actions
</ButtonMenu>The trigger renders a down/up arrow automatically. It opens on button activation and closes after selection, on Escape, or on an outside click by default.
Menu entries
entries is the required list of menu content. It accepts menu items, labels, and dividers:
const entries = [
{ type: 'label', label: 'Project actions' },
{
key: 'new',
label: 'New document',
description: 'Start with a blank file',
icon: <FileText />,
shortcut: '⌘ N',
onClick: () => createDocument(),
},
{
key: 'more',
label: 'More options',
submenu: [
{ key: 'archive', label: 'Archive' },
{ key: 'move', label: 'Move to folder' },
],
},
{ type: 'divider' },
{ key: 'delete', label: 'Delete', danger: true, disabled: false },
];
<ButtonMenu entries={entries}>Project</ButtonMenu>MenuItem fields
| Field | Type | Description |
|---|---|---|
key | string | Required unique item key. |
label | ReactNode | Visible item label. |
description | ReactNode | Supporting text below the label. |
icon | ReactNode | Leading icon. |
image | string | Image URL rendered as the leading visual. |
shortcut | ReactNode | Keyboard shortcut hint. |
disabled | boolean | Disables the item. |
danger | boolean | Applies a destructive style. |
checked | boolean | Shows the checked indicator. |
onClick | () => void | Runs when the item is selected. |
submenu | MenuEntry[] | Nested submenu entries. |
Use { type: 'divider', key?: string } for a separator or { type: 'label', key?: string, label: ReactNode } for a non-interactive group label.
Placement, width, and trigger styling
<ButtonMenu
placement="top-end"
menuWidth={320}
triggerProps={{ variant: 'outline', size: 'sm', iconLeft: <Settings /> }}
entries={entries}
>
More
</ButtonMenu>placement accepts bottom-start, bottom-end, top-start, and top-end. menuWidth accepts a number in pixels or a CSS length string. Set matchTriggerWidth to make the menu width equal to the trigger width.
triggerProps forwards ButtonProps and native button attributes, except children and iconRight. The component owns the trigger label and its open/closed arrow. You can pass variant, size, iconLeft, loading, fullWidth, circle, keyboard hints, onClick, and other supported button props.
Controlled and uncontrolled state
const [open, setOpen] = useState(false);
<ButtonMenu
open={open}
onOpenChange={setOpen}
entries={entries}
>
{open ? 'Close actions' : 'Open actions'}
</ButtonMenu>Use defaultOpen when only the initial state needs to be open. Use open with onOpenChange when the parent owns the state. onOpenChange receives true or false after trigger activation, outside click, Escape, or selection closing.
Close behavior
<ButtonMenu
closeOnSelect={false}
closeOnOutsideClick={false}
closeOnEscape={false}
entries={entries}
>
Keep open
</ButtonMenu>closeOnSelectdefaults totrueand controls closing after a top-level item selection.closeOnOutsideClickdefaults totrueand closes when the pointer is pressed outside the component.closeOnEscapedefaults totrue; Escape closes the menu and restores focus to the trigger.disabledprevents the trigger from opening the menu.
Menu configuration and selection
menuProps forwards configuration to the internal Menu. ButtonMenu keeps ownership of entries, aria-label, and width; those values cannot be overridden through menuProps.
const [selectedKeys, setSelectedKeys] = useState(['grid']);
<ButtonMenu
closeOnSelect={false}
entries={[
{ key: 'list', label: 'List view' },
{ key: 'grid', label: 'Grid view' },
]}
menuProps={{
selectable: true,
selectedKeys,
size: 'sm',
variant: 'filled',
footer: 'Choose a view',
onSelect: (key, item) => {
setSelectedKeys([key]);
console.log('Selected:', item.label);
},
}}
>
View
</ButtonMenu>Supported menuProps values are selectable, selectedKeys, defaultSelectedKeys, openKeys, defaultOpenKeys, onSelect, onOpenChange, size (sm | default | lg), variant (default | compact | filled), maxHeight, footer, role (menu | listbox), and className.
Keyboard and accessibility
- The trigger exposes
aria-haspopup="menu",aria-expanded, andaria-controls. - The menu receives focus when it opens.
Escapecloses the menu and returns focus to the trigger when enabled.- The menu supports Arrow Up/Down, Home, End, Enter, and Space.
- Disabled entries cannot be activated.
- Give the trigger a meaningful visible label or pass
aria-labelwhen the visible text is not sufficient. idcontrols the generated menu ID referenced byaria-controls; if omitted, a stable ID is generated.
ConfigProvider defaults
The following defaults can be configured globally. Instance props take precedence over provider values.
<ConfigProvider
components={{
buttonMenu: {
placement: 'bottom-end',
menuWidth: 320,
matchTriggerWidth: false,
closeOnEscape: true,
closeOnOutsideClick: true,
closeOnSelect: true,
},
}}
>
<ButtonMenu entries={entries}>Actions</ButtonMenu>
</ConfigProvider>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
entries | MenuEntry[] | required | Items, labels, dividers, and submenus. |
children | ReactNode | 'Options' | Trigger content. |
triggerProps | Omit<ButtonProps, 'children' | 'iconRight'> | — | Button styling, icons, loading, events, and native attributes. |
menuProps | Omit<MenuProps, 'entries' | 'aria-label' | 'width'> | — | Internal menu configuration. |
open | boolean | — | Controlled open state. |
defaultOpen | boolean | false | Initial uncontrolled open state. |
onOpenChange | (open: boolean) => void | — | Open-state event. |
placement | 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' | 'bottom-start' | Popover placement. |
menuWidth | number | string | 280 | Menu width; numbers are pixels. |
matchTriggerWidth | boolean | false | Match menu width to trigger width. |
closeOnSelect | boolean | true | Close after top-level selection. |
closeOnOutsideClick | boolean | true | Close on outside pointer press. |
closeOnEscape | boolean | true | Close on Escape and restore focus. |
disabled | boolean | false | Disable the trigger. |
aria-label | string | 'Open menu' | Accessible label for trigger/menu. |
id | string | generated | Menu ID used by aria-controls. |
className | string | — | Root wrapper class. |
ButtonMenu does not expose imperative methods through a forwarded ref. Use its props and events, triggerProps.onClick, entries[].onClick, and menuProps.onSelect/menuProps.onOpenChange for application behavior.