Components · Actions

Menu

A popover list of actions, anchored to a trigger. Use Menu when you need to surface several related actions without paying for permanent space — Edit, Duplicate, Archive, Delete, and the rest of the secondary moves a row or page can offer.

Documentedby Derek Fidler

Click to open

Overview

The Menu lives in @flatpay-dk/ui and renders as a popover anchored to a trigger button. It supports a flat list of items, optional sections with headings, leading icons, trailing meta (keyboard shortcuts, counts, ✓), a danger variant for destructive actions, and an optional search input at the top. Open and close behavior, click-outside, Escape, and arrow-key navigation are all handled internally.

Reach for Menu sparingly

Primary actions belong in the page chrome — Save in a header, the big primary Button. Menu is for the secondary actions that don’t earn permanent space: Archive, Duplicate, Export, Delete. If you find yourself opening menus to find common actions, promote one of them to a real button.

Anatomy

The popover surface is the canonical card geometry — 8 px radius, 1 px border, shadow-md per the Elevation map for dropdowns and popovers. Items inside are drawn with the brand’s product-UI vocabulary: Inter Tight at 14 px medium, leading icon at 16 px, 4 px hover radius, 12 × 8 px padding.

  1. Surface

    rounded-lg (8 px), 1 px border, shadow-md, off-white background. Min-width 160 px; the menu sizes to its widest item.

  2. Item

    Real <button> with role="menuitem". 14 px Inter Tight medium, 16 × 12 px padding, 4 px radius on hover.

  3. Leading icon

    16 px Material Outlined glyph, currentColor, optional. Reinforces the verb — never replaces the text.

  4. Trailing meta

    Keyboard shortcut, count, or check mark. Inter Tight 12 px regular, 50% foreground. Optional.

  5. Separator

    1 px divider between unrelated groups. Use sparingly — sections with headings are the stronger cue.

  6. Section heading

    Optional ALL-CAPS .eyebrow label above a group. Use when a menu has 4+ items.

Items

Each item is an action. Write labels in the verb + noun format — Edit details, Export as CSV, Duplicate product, Archive order. The label says what will happen when the item is activated.

Default · with leading icons

States

Four visible item states across two variants. Default and Danger share their state geometry — only the colour ramp shifts. Disabled is for actions that are contextually unavailable (Refund order when already refunded). If an action is never available, remove it from the menu entirely.

Default
Hover
Focus
Disabled
Default
Danger

Selection mode

When the menu’s job is to pick rather than do — a filter list, a sort order, a workspace switcher — swap <MenuItem> for one of the selectable variants. They render the same surface and inherit the same keyboard behavior; the difference is the role and how the leading slot reads.

  • <MenuItemCheckbox> — multi-select. Carries role="menuitemcheckbox", shows a checkmark in the leading slot when checked, and stays open on toggle so users can pick several in a row.
  • <MenuItemRadio> — single-select. Carries role="menuitemradio", shows a checkmark when selected, and closes on activation. The consumer tracks which radio is selected; the component is controlled.
  • <MenuRadioGroup> — wraps a set of radios and owns the selected value, so each child only declares its own value. Mirrors the Atlassian DropdownItemRadioGroup pattern. The group can carry an ALL-CAPS label (rendered + wired via aria-labelledby).
  • Optional description. Every selection item accepts a description prop — a short supporting line that renders below the label in muted weight. Use it sparingly: when a label like “Auto archive” needs a sentence to make the consequence clear.
  • Don’t mix selection items with action items. A menu either picks or does. If both are needed, split them — a filter menu plus an action menu, side by side, reads better than one mixed list.

Multi-select · Filter

MenuItemCheckbox · stays open as the user toggles each row.

Single-select · Sort

MenuItemRadio inside MenuRadioGroup · closes on pick.

Checkbox · with descriptions

Each row gets a short supporting line. The leading checkmark slot stays reserved so labels stack cleanly across selected and unselected rows.

Radio group · with descriptions

Three density options. The group owns the selected value; each radio just declares its own.

Sections

When a menu has 4+ items, group them into sections with ALL-CAPS headings. Sections separate concerns — “Manage” actions stay together, destructive actions move into a labelled “Danger zone”. The heading is decorative for the eye and structural for screen readers (the section maps to role="group" with an aria-label).

Two sections + a separator

Optional search input at the top of the menu, used when the list is long enough that scanning isn’t the fastest path. Filtering is the consumer’s responsibility — the input emits its value via onValueChange; the consumer renders the matching subset of MenuItems. Don’t add search to short menus; the affordance feels noisy when there are five things to choose from.

With <MenuSearch />

Placement

Four placements — bottom-start (default), bottom-end, top-start, top-end. Default to bottom-start; flip to bottom-end when the trigger sits at the right edge of its container so the menu opens inward, not into the gutter.

placement="bottom-start"

Manage

placement="bottom-end"

Manage

Behavior

The component handles open / close, focus, and keyboard navigation internally. You wire up onSelect on each item; the menu closes on activation. Override the open state with the controlled open / onOpenChange pair if you need to coordinate with another piece of UI.

  • Open. Click the trigger. Or focus the trigger and press Enter, Space, or . Focus moves to the first item.
  • Navigate. / moves through items, wrapping at the ends. Home / End jump to the first / last item. Disabled items are skipped.
  • Activate. Click an item, or press Enter / Space while it has focus. The menu closes; focus returns to the trigger via Escape.
  • Type-ahead.Typing a single letter jumps focus to the next item whose label starts with that letter. Continue typing within ~500 ms to refine the match (“ar” lands on Archive rather than Audit).
  • Close. Escape closes and returns focus to the trigger. Clicking outside closes silently. Tab closes and lets focus advance to the next page element.
  • Stay open after activation. Pass closeOnSelect={false} on a MenuItemfor actions that should leave the menu up — a “Refresh” row inside an otherwise-stable filter menu, for instance. Checkbox items default to staying open.

Accessibility

  • ARIA. Trigger carries aria-haspopup="menu" and aria-expanded; the popover renders role="menu" and is labelled by the trigger via aria-labelledby. Items render role="menuitem".
  • Focus.First item is focused on open. Focus is kept inside the menu while it’s open; Tab closes it. Focus returns to the trigger when the menu closes.
  • Disabled items carry aria-disabled="true" and are skipped by the arrow-key cycle.
  • Icon-only triggers need an aria-labelon the underlying Button — the menu’s name is taken from the trigger’s accessible name, not its visible label.

Code

Composable API — assemble the menu out of Trigger + Content + Items rather than passing a config object. The trigger can be any Button variant.

tsx

import {
  Menu,
  MenuTrigger,
  MenuContent,
  MenuItem,
  MenuSection,
  MenuSeparator,
  Button,
} from "@flatpay-dk/ui";

<Menu>
  <MenuTrigger>
    <Button variant="secondary">Manage</Button>
  </MenuTrigger>
  <MenuContent placement="bottom-start" width={200}>
    <MenuSection heading="Manage">
      <MenuItem leadingIcon={<EditIcon />} onSelect={onEdit}>
        Edit details
      </MenuItem>
      <MenuItem leadingIcon={<DuplicateIcon />} onSelect={onDuplicate}>
        Duplicate
      </MenuItem>
      <MenuItem
        leadingIcon={<ExportIcon />}
        trailingMeta="⌘E"
        onSelect={onExport}
      >
        Export as CSV
      </MenuItem>
    </MenuSection>
    <MenuSeparator />
    <MenuSection heading="Danger zone">
      <MenuItem
        variant="danger"
        leadingIcon={<TrashIcon />}
        onSelect={onDelete}
      >
        Delete
      </MenuItem>
    </MenuSection>
  </MenuContent>
</Menu>

The trigger is the child

<MenuTrigger> doesn’t render its own button — it clones the single child element and attaches the trigger props directly. Pass a real interactive element (Button, link, custom component with forwardRef); never wrap one button inside another.

Controlled open state

Pass open and onOpenChange to <Menu> when you need to drive the menu from outside — opening it in response to a keyboard shortcut, or closing it after a network round-trip. Otherwise the component manages its own state.

Selection mode

tsx

import {
  Menu,
  MenuTrigger,
  MenuContent,
  MenuItemCheckbox,
  MenuItemRadio,
  MenuRadioGroup,
  Button,
} from "@flatpay-dk/ui";

// Multi-select — stays open as the user toggles each row
const [filters, setFilters] = useState({ demoReady: true, building: true });

<Menu>
  <MenuTrigger><Button variant="secondary">Filter</Button></MenuTrigger>
  <MenuContent>
    <MenuItemCheckbox
      checked={filters.demoReady}
      onCheckedChange={(v) => setFilters({ ...filters, demoReady: v })}
      description="Live demo link is up and tracking events"
    >
      Demo ready
    </MenuItemCheckbox>
    <MenuItemCheckbox
      checked={filters.building}
      onCheckedChange={(v) => setFilters({ ...filters, building: v })}
    >
      Building
    </MenuItemCheckbox>
  </MenuContent>
</Menu>

// Single-select — group owns the value, each radio just declares its own
const [density, setDensity] = useState<"compact" | "default" | "spacious">("default");

<Menu>
  <MenuTrigger><Button variant="secondary">Density</Button></MenuTrigger>
  <MenuContent>
    <MenuRadioGroup label="Density" value={density} onValueChange={setDensity}>
      <MenuItemRadio value="compact" description="The most rows on screen">
        Compact
      </MenuItemRadio>
      <MenuItemRadio value="default" description="Balanced for everyday work">
        Default
      </MenuItemRadio>
      <MenuItemRadio value="spacious" description="Generous spacing, easier scanning">
        Spacious
      </MenuItemRadio>
    </MenuRadioGroup>
  </MenuContent>
</Menu>

// Standalone radio (no group) — caller manages selection across siblings
<MenuItemRadio selected={sort === "newest"} onSelect={() => setSort("newest")}>
  Newest first
</MenuItemRadio>

Best practices

Do

Write verb + noun labels — the user reads the menu and knows what each item does.

Don't

Don't use vague labels like Options, More, or Settings. They force the user to open the menu to find out what's inside.

Do

Group destructive actions in a Danger zone section, separated from the safer ones.

Don't

Don't interleave destructive items with safer ones. The eye loses the gradient of consequence.

  • Reserve for secondary actions.Primary actions live in the page chrome. Menu carries the moves that don’t earn permanent space — Archive, Duplicate, Export.
  • Use icons to reinforce meaning.Edit gets the pencil, Delete gets the trash, Export gets the down-arrow. Icons clarify the verb; they don’t replace the label.
  • Cap at 10–12 items per menu. Beyond that, scanning becomes work. Either split the menu by section, surface a search input, or rethink the affordance — a side panel may serve better.
  • Disable temporarily, remove permanently. Use disabled for actions that are unavailable right now (Refund when already refunded). If the action is never available for this user or this row, drop it from the menu entirely.
  • One destructive action per menu. Any more and the user is being asked to make several high-stakes choices at once. Move the rest into a confirmation dialog.

Props

<Menu>

PropTypeDefaultDescription
openbooleanControlled open state. Omit for self-managed open / close.
onOpenChange(open: boolean) => voidFires when the menu wants to open or close. Required when controlled.
defaultOpenbooleanfalseInitial open state in uncontrolled mode.
children*ReactNodeShould contain a MenuTrigger and a MenuContent.

<MenuTrigger>

PropTypeDefaultDescription
children*ReactElementA single interactive element (Button, link, custom forwardRef component). MenuTrigger clones this element and attaches the menu's ARIA + click + keyboard wiring directly — it does not render a wrapper button.

<MenuContent>

PropTypeDefaultDescription
children*ReactNodeMenuItem, MenuSection, MenuSeparator, MenuSearch.
placement"bottom-start" | "bottom-end" | "top-start" | "top-end""bottom-start"Where the menu opens relative to the trigger.
widthnumber | stringPixel or CSS width. Defaults to fit-content with min-width 160 px.
classNamestringForwarded to the popover surface.

<MenuItem>

PropTypeDefaultDescription
children*ReactNodeThe label. Verb + noun.
onSelect() => voidFires when the item is activated. The menu closes automatically afterwards.
leadingIconReactNode16 px icon rendered before the label. Use Material Outlined.
trailingMetaReactNodeRight-aligned meta — keyboard shortcut, count, ✓.
descriptionReactNodeOptional supporting text rendered below the label in muted weight. Use sparingly — labels should still carry the meaning on their own.
variant"default" | "danger""default"Set to danger for destructive actions.
closeOnSelectbooleantrueWhether the menu closes after activation. Set to false for actions that should leave the menu open.
disabledbooleanfalseUse only when the action is contextually unavailable. Permanent removal beats persistent disable.

<MenuItemCheckbox>

PropTypeDefaultDescription
checked*booleanCurrent checked state. Component is fully controlled.
onCheckedChange(checked: boolean) => voidFires with the next checked state when the user toggles.
children*ReactNodeThe label.
trailingMetaReactNodeRight-aligned meta — keyboard shortcut, count.
descriptionReactNodeOptional supporting text rendered below the label in muted weight.
closeOnSelectbooleanfalseWhether the menu closes on toggle. Defaults to false — checkboxes are usually multi-select.
disabledbooleanfalseDisables the row.

<MenuRadioGroup>

PropTypeDefaultDescription
value*string | undefinedThe currently selected value. Compared by strict equality against each MenuItemRadio's value.
onValueChange*(value: string) => voidFires with the next value when a child radio is picked.
labelstringOptional ALL-CAPS heading rendered above the items and wired via aria-labelledby.
closeOnSelectbooleantrueWhether picking a radio closes the menu. Group setting takes precedence over per-item closeOnSelect.
children*ReactNodeMenuItemRadio elements.

<MenuItemRadio>

PropTypeDefaultDescription
valuestringRequired when used inside a MenuRadioGroup. The group compares this against its own value to know which radio is selected.
selectedbooleanStandalone mode only — whether this is the currently selected option. Ignored when inside a MenuRadioGroup.
onSelect() => voidStandalone mode only — fires when the user picks this option. Ignored when inside a MenuRadioGroup (the group's onValueChange fires instead).
children*ReactNodeThe label.
trailingMetaReactNodeRight-aligned meta.
descriptionReactNodeOptional supporting text rendered below the label in muted weight.
closeOnSelectbooleantrueStandalone mode only. Inside a group, the group's setting wins.
disabledbooleanfalseDisables the row.

<MenuSection>

PropTypeDefaultDescription
headingstringALL-CAPS label rendered above the items. Optional.
children*ReactNodeMenuItems belonging to this section.

<MenuSearch>

PropTypeDefaultDescription
valuestringControlled value. Filter your items based on it.
onValueChange(value: string) => voidFires on every keystroke.
placeholderstring"Search…"Hint text inside the input.
leadingIconReactNodeOverride the default magnifier glyph.