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.
Surface
rounded-lg (8 px), 1 px border, shadow-md, off-white background. Min-width 160 px; the menu sizes to its widest item.
Item
Real <button> with role="menuitem". 14 px Inter Tight medium, 16 × 12 px padding, 4 px radius on hover.
Leading icon
16 px Material Outlined glyph, currentColor, optional. Reinforces the verb — never replaces the text.
Trailing meta
Keyboard shortcut, count, or check mark. Inter Tight 12 px regular, 50% foreground. Optional.
Separator
1 px divider between unrelated groups. Use sparingly — sections with headings are the stronger cue.
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.
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. Carriesrole="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. Carriesrole="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 ownvalue. Mirrors the AtlassianDropdownItemRadioGrouppattern. The group can carry an ALL-CAPSlabel(rendered + wired viaaria-labelledby).- Optional description. Every selection item accepts a
descriptionprop — 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.
Filter
Demo readyBuildingArchivedKilledMulti-select · Filter
MenuItemCheckbox · stays open as the user toggles each row.
Sort by
Newest firstOldest firstA → ZZ → ASingle-select · Sort
MenuItemRadio inside MenuRadioGroup · closes on pick.
Notifications
Build errorsWhen a manifest fails to parseDemo readyWhen a teammate ships a demoWeekly summaryWeekly digest of new prototypesAuto-archiveOwner unavailable; require adminCheckbox · with descriptions
Each row gets a short supporting line. The leading checkmark slot stays reserved so labels stack cleanly across selected and unselected rows.
Density
CompactThe most rows on screenDefaultBalanced for everyday workSpaciousGenerous spacing, easier scanningRadio 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).
Manage
Edit detailsDuplicateExport as CSVDanger zone
ArchiveDeleteTwo sections + a separator
Search
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"
placement="bottom-end"
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 aMenuItemfor 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"andaria-expanded; the popover rendersrole="menu"and is labelled by the trigger viaaria-labelledby. Items renderrole="menuitem". - Focus.First item is focused on open. Focus is kept inside the menu while it’s open;
Tabcloses 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.
Manage
Edit detailsDuplicate productDanger zone
Archive productDelete productDo
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
disabledfor 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>
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state. Omit for self-managed open / close. |
| onOpenChange | (open: boolean) => void | — | Fires when the menu wants to open or close. Required when controlled. |
| defaultOpen | boolean | false | Initial open state in uncontrolled mode. |
| children* | ReactNode | — | Should contain a MenuTrigger and a MenuContent. |
<MenuTrigger>
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactElement | — | A 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>
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactNode | — | MenuItem, MenuSection, MenuSeparator, MenuSearch. |
| placement | "bottom-start" | "bottom-end" | "top-start" | "top-end" | "bottom-start" | Where the menu opens relative to the trigger. |
| width | number | string | — | Pixel or CSS width. Defaults to fit-content with min-width 160 px. |
| className | string | — | Forwarded to the popover surface. |
<MenuItem>
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactNode | — | The label. Verb + noun. |
| onSelect | () => void | — | Fires when the item is activated. The menu closes automatically afterwards. |
| leadingIcon | ReactNode | — | 16 px icon rendered before the label. Use Material Outlined. |
| trailingMeta | ReactNode | — | Right-aligned meta — keyboard shortcut, count, ✓. |
| description | ReactNode | — | Optional 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. |
| closeOnSelect | boolean | true | Whether the menu closes after activation. Set to false for actions that should leave the menu open. |
| disabled | boolean | false | Use only when the action is contextually unavailable. Permanent removal beats persistent disable. |
<MenuItemCheckbox>
| Prop | Type | Default | Description |
|---|---|---|---|
| checked* | boolean | — | Current checked state. Component is fully controlled. |
| onCheckedChange | (checked: boolean) => void | — | Fires with the next checked state when the user toggles. |
| children* | ReactNode | — | The label. |
| trailingMeta | ReactNode | — | Right-aligned meta — keyboard shortcut, count. |
| description | ReactNode | — | Optional supporting text rendered below the label in muted weight. |
| closeOnSelect | boolean | false | Whether the menu closes on toggle. Defaults to false — checkboxes are usually multi-select. |
| disabled | boolean | false | Disables the row. |
<MenuRadioGroup>
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | undefined | — | The currently selected value. Compared by strict equality against each MenuItemRadio's value. |
| onValueChange* | (value: string) => void | — | Fires with the next value when a child radio is picked. |
| label | string | — | Optional ALL-CAPS heading rendered above the items and wired via aria-labelledby. |
| closeOnSelect | boolean | true | Whether picking a radio closes the menu. Group setting takes precedence over per-item closeOnSelect. |
| children* | ReactNode | — | MenuItemRadio elements. |
<MenuItemRadio>
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Required when used inside a MenuRadioGroup. The group compares this against its own value to know which radio is selected. |
| selected | boolean | — | Standalone mode only — whether this is the currently selected option. Ignored when inside a MenuRadioGroup. |
| onSelect | () => void | — | Standalone mode only — fires when the user picks this option. Ignored when inside a MenuRadioGroup (the group's onValueChange fires instead). |
| children* | ReactNode | — | The label. |
| trailingMeta | ReactNode | — | Right-aligned meta. |
| description | ReactNode | — | Optional supporting text rendered below the label in muted weight. |
| closeOnSelect | boolean | true | Standalone mode only. Inside a group, the group's setting wins. |
| disabled | boolean | false | Disables the row. |
<MenuSection>
| Prop | Type | Default | Description |
|---|---|---|---|
| heading | string | — | ALL-CAPS label rendered above the items. Optional. |
| children* | ReactNode | — | MenuItems belonging to this section. |
<MenuSearch>
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Controlled value. Filter your items based on it. |
| onValueChange | (value: string) => void | — | Fires on every keystroke. |
| placeholder | string | "Search…" | Hint text inside the input. |
| leadingIcon | ReactNode | — | Override the default magnifier glyph. |