Mosmox UI
Current version
GitHubnpmjs.com
Navigation & Selection

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.

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>
FieldTypeDescription
keystringRequired unique item key.
labelReactNodeVisible item label.
descriptionReactNodeSupporting text below the label.
iconReactNodeLeading icon.
imagestringImage URL rendered as the leading visual.
shortcutReactNodeKeyboard shortcut hint.
disabledbooleanDisables the item.
dangerbooleanApplies a destructive style.
checkedbooleanShows the checked indicator.
onClick() => voidRuns when the item is selected.
submenuMenuEntry[]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>
  • closeOnSelect defaults to true and controls closing after a top-level item selection.
  • closeOnOutsideClick defaults to true and closes when the pointer is pressed outside the component.
  • closeOnEscape defaults to true; Escape closes the menu and restores focus to the trigger.
  • disabled prevents the trigger from opening the menu.

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, and aria-controls.
  • The menu receives focus when it opens.
  • Escape closes 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-label when the visible text is not sufficient.
  • id controls the generated menu ID referenced by aria-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

PropTypeDefaultDescription
entriesMenuEntry[]requiredItems, labels, dividers, and submenus.
childrenReactNode'Options'Trigger content.
triggerPropsOmit<ButtonProps, 'children' | 'iconRight'>—Button styling, icons, loading, events, and native attributes.
menuPropsOmit<MenuProps, 'entries' | 'aria-label' | 'width'>—Internal menu configuration.
openboolean—Controlled open state.
defaultOpenbooleanfalseInitial uncontrolled open state.
onOpenChange(open: boolean) => void—Open-state event.
placement'bottom-start' | 'bottom-end' | 'top-start' | 'top-end''bottom-start'Popover placement.
menuWidthnumber | string280Menu width; numbers are pixels.
matchTriggerWidthbooleanfalseMatch menu width to trigger width.
closeOnSelectbooleantrueClose after top-level selection.
closeOnOutsideClickbooleantrueClose on outside pointer press.
closeOnEscapebooleantrueClose on Escape and restore focus.
disabledbooleanfalseDisable the trigger.
aria-labelstring'Open menu'Accessible label for trigger/menu.
idstringgeneratedMenu ID used by aria-controls.
classNamestring—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.