Menu

Drops a list of actions below a trigger, driveable entirely by keyboard.

import { Menu } from "@usefragments/ui";Source

Preview

<Menu>
  <Menu.Trigger render={<Button variant="soft" />}>Actions</Menu.Trigger>
  <Menu.Content>
    <Menu.Item onSelect={() => {}}>Edit</Menu.Item>
    <Menu.Item onSelect={() => {}}>Duplicate</Menu.Item>
    <Menu.Separator />

Usage

Use when

  • Actions that do not fit in the toolbar
  • Right-click context actions
  • Account and profile actions
  • Related actions that read better grouped

Avoid when

  • Picking a value for a form (use Select)
  • Moving between pages or views (use Tabs, Header.NavMenu or Sidebar)
  • One of a few visible choices (use RadioGroup)

Implementation notes

  • Group related actions and separate the rest
  • Show the keyboard shortcut where one exists
  • Mark destructive actions with tone="danger": the label turns danger ink, the highlight stays neutral
  • A chosen state is a Menu.CheckboxItem or a Menu.RadioItem, never a plain item with a mark
  • Raise the row cap with Menu.Content maxVisibleItems, never by setting --fui-popup-viewport-rows in CSS
  • Past a dozen rows, reach for Command instead
  • Nest at most one level of submenu
  • Pass render={<Button />} to Menu.Trigger to use a library control

Installation

pnpm add @usefragments/ui

Import styles once from @usefragments/ui/styles. See the installation guide for framework and theme setup.

Anatomy

Import Menu once, then compose its parts with dot notation.

  • Menu.Trigger
  • Menu.Content
  • Menu.Item
  • Menu.CheckboxItem
  • Menu.RadioGroup
  • Menu.RadioItem
  • Menu.Group
  • Menu.GroupLabel
  • Menu.Separator
  • Menu.Submenu
  • Menu.SubmenuTrigger

Plain actions, one per row.

<Menu>
  <Menu.Trigger render={<Button variant="soft" />}>Actions</Menu.Trigger>
  <Menu.Content>
    <Menu.Item onSelect={() => {}}>Edit</Menu.Item>
    <Menu.Item onSelect={() => {}}>Duplicate</Menu.Item>
    <Menu.Separator />
    <Menu.Item tone="danger" onSelect={() => {}}>Delete</Menu.Item>
  </Menu.Content>
</Menu>

Examples

With Shortcuts

Trailing keys teach the faster path.

<Menu>
  <Menu.Trigger render={<Button variant="soft" />}>Edit</Menu.Trigger>
  <Menu.Content>
    <Menu.Item shortcut="Ctrl+Z" onSelect={() => {}}>Undo</Menu.Item>
    <Menu.Item shortcut="Ctrl+Y" onSelect={() => {}}>Redo</Menu.Item>
    <Menu.Separator />

With Groups

Labels separate unrelated actions.

<Menu>
  <Menu.Trigger render={<Button variant="soft" />}>Options</Menu.Trigger>
  <Menu.Content>
    <Menu.Group>
      <Menu.GroupLabel>View</Menu.GroupLabel>
      <Menu.Item onSelect={() => {}}>Zoom In</Menu.Item>

With Checkboxes

Rows that stay open while you toggle them.

<Menu>
  <Menu.Trigger render={<Button variant="soft" />}>Display</Menu.Trigger>
  <Menu.Content>
    <Menu.CheckboxItem defaultChecked>Show Grid</Menu.CheckboxItem>
    <Menu.CheckboxItem defaultChecked>Show Rulers</Menu.CheckboxItem>
    <Menu.CheckboxItem>Show Guides</Menu.CheckboxItem>

With Radio Items

A choice of one: the chosen row carries the check and the other rows keep its column.

<Menu>
  <Menu.Trigger render={<Button variant="soft" />}>Sort</Menu.Trigger>
  <Menu.Content>
    <Menu.RadioGroup defaultValue="updated">
      <Menu.RadioItem value="updated">Last updated</Menu.RadioItem>
      <Menu.RadioItem value="name">Name</Menu.RadioItem>

With Submenu

Secondary options nest one level deep.

<Menu>
  <Menu.Trigger render={<Button variant="soft" />}>File</Menu.Trigger>
  <Menu.Content>
    <Menu.Item onSelect={() => {}}>New File</Menu.Item>
    <Menu.Item onSelect={() => {}}>Open</Menu.Item>
    <Menu.Separator />

render lets a link open it.

<Menu>
  <Menu.Trigger render={<a href="#menu-actions" />}>Open menu</Menu.Trigger>
  <Menu.Content>
    <Menu.Item onSelect={() => {}}>Inspect</Menu.Item>
    <Menu.Item onSelect={() => {}}>Rename</Menu.Item>
  </Menu.Content>

API reference

Component props
Prop
Type
Default
Description
CheckboxItem.checkedboolean | undefinedNot set—
CheckboxItem.childrenRequiredReact.ReactNodeNot set—
CheckboxItem.defaultCheckedboolean | undefinedNot set—
CheckboxItem.disabledboolean | undefinedNot set—
CheckboxItem.onCheckedChange((checked: boolean) => void) | undefinedNot set—
CheckboxItem.shortcutstring | undefinedNot setKeyboard shortcut shown at the end
childrenRequiredReact.ReactNodeNot setMenu trigger and content
Content.align"center" | "start" | "end" | undefinedcenterstartendNot set—
Content.childrenRequiredReact.ReactNodeNot set—
Content.loadingboolean | undefinedNot setItems are still loading on open: the menu is busy at once, and after a second a spinner row ("Loading…") shows below the rows it already has.
Content.maxVisibleItemsnumber | undefinedNot setMaximum number of visible rows before scrolling. Shows half of the next row as a scroll hint.
Content.side"top" | "bottom" | "left" | "right" | undefinedtopbottomleftrightNot set—
defaultOpenboolean | undefinedfalseDefault open state (uncontrolled)
Group.childrenRequiredReact.ReactNodeNot set—
GroupLabel.childrenRequiredReact.ReactNodeNot set—
Item.childrenRequiredReact.ReactNodeNot set—
Item.disabledboolean | undefinedNot set—
Item.iconReact.ReactNodeNot setLeading icon
Item.onSelect((event: React.MouseEvent<HTMLElement>) => void) | undefinedNot setCalled when the item is chosen by pointer or keyboard
Item.shortcutstring | undefinedNot setKeyboard shortcut shown at the end, such as "⌘D"
Item.toneMenuItemTone | undefineddangerneutralNot set`danger` colours the label and icon ink; the highlight stays neutral.
modalboolean | undefinedtrueWhether menu is modal
onOpenChange((open: boolean) => void) | undefinedNot setCalled when open state changes
openboolean | undefinedNot setControlled open state
RadioGroup.childrenRequiredReact.ReactNodeNot set—
RadioGroup.defaultValuestring | undefinedNot set—
RadioGroup.onValueChange((value: string) => void) | undefinedNot set—
RadioGroup.valuestring | undefinedNot set—
RadioItem.childrenRequiredReact.ReactNodeNot set—
RadioItem.disabledboolean | undefinedNot set—
RadioItem.shortcutstring | undefinedNot setKeyboard shortcut shown at the end
RadioItem.valueRequiredstringNot set—
Submenu.childrenRequiredReact.ReactNodeNot set—
Submenu.defaultOpenboolean | undefinedNot set—
Submenu.onOpenChange((open: boolean) => void) | undefinedNot set—
Submenu.openboolean | undefinedNot set—
SubmenuTrigger.childrenRequiredReact.ReactNodeNot set—
SubmenuTrigger.disabledboolean | undefinedNot set—
SubmenuTrigger.iconReact.ReactNodeNot set—

Accessibility

  • Full keyboard navigation with arrow keys
  • Type-ahead search for items
  • Focus returns to trigger on close
  • Proper ARIA menu roles
  • ArrowRight opens submenus, ArrowLeft closes them
  • SelectalternativeUse Select for form field selection
  • PopoveralternativeUse Popover for rich non-action content

Next steps