Browse docs
Browse docs
Trigger a list of actions. Keyboard-accessible by default.
import { Menu, MenuTrigger, MenuContent, MenuItem } from '@dashforge/tw';
<Menu>
<MenuTrigger><Button>Actions ▾</Button></MenuTrigger>
<MenuContent>
<MenuItem onClick={onEdit}>Edit</MenuItem>
<MenuItem onClick={onDuplicate}>Duplicate</MenuItem>
<MenuItem color="danger" onClick={onDelete}>Delete</MenuItem>
</MenuContent>
</Menu><Menu>
<MenuTrigger><Button variant="ghost">User ▾</Button></MenuTrigger>
<MenuContent>
<MenuLabel>Account</MenuLabel>
<MenuItem onClick={onProfile} icon={<User />}>Profile</MenuItem>
<MenuItem onClick={onSettings} icon={<Settings />}>Settings</MenuItem>
<MenuSeparator />
<MenuItem onClick={onSignOut} icon={<LogOut />} color="danger">Sign out</MenuItem>
</MenuContent>
</Menu>Trigger + content + items, with one destructive color="danger" item:
import { Button, Menu, MenuContent, MenuItem, MenuTrigger } from '@dashforge/tw';
<Menu>
<MenuTrigger><Button variant="ghost">Actions ▾</Button></MenuTrigger>
<MenuContent>
<MenuItem onClick={() => alert('Edit')}>Edit</MenuItem>
<MenuItem onClick={() => alert('Duplicate')}>Duplicate</MenuItem>
<MenuItem onClick={() => alert('Archive')}>Archive</MenuItem>
<MenuItem color="danger" onClick={() => alert('Delete')}>Delete</MenuItem>
</MenuContent>
</Menu>MenuTrigger is a Radix Slot — use asChild to render the trigger styling onto your own element. Common patterns: Button (above), IconButton (avatar dropdown), Chip, Card, etc.
<Menu>
<MenuTrigger asChild>
<IconButton aria-label="More options" variant="ghost">
<MoreVertical />
</IconButton>
</MenuTrigger>
<MenuContent>
<MenuItem onClick={onEdit}>Edit</MenuItem>
<MenuItem onClick={onArchive}>Archive</MenuItem>
</MenuContent>
</Menu>{/* With leading icon */}
<MenuItem icon={<Pencil />} onClick={onEdit}>Edit</MenuItem>
{/* With trailing endIcon (keyboard shortcut, badge, etc.) */}
<MenuItem onClick={onSave} endIcon={<kbd className="text-xs">⌘S</kbd>}>Save</MenuItem>
{/* Disabled */}
<MenuItem disabled icon={<Lock />}>Locked</MenuItem>
{/* Selected — toggles aria-checked */}
<MenuItem selected={view === 'grid'} onClick={() => setView('grid')}>
Grid view
</MenuItem>
{/* Danger styling (destructive action) */}
<MenuItem color="danger" icon={<Trash2 />} onClick={onDelete}>
Delete
</MenuItem>Group related items with non-interactive labels and visual dividers; add leading icons via the icon prop:
import {
Button, Menu, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuTrigger,
} from '@dashforge/tw';
import { User, Settings, LogOut } from 'lucide-react';
<Menu>
<MenuTrigger><Button variant="outline">User ▾</Button></MenuTrigger>
<MenuContent>
<MenuLabel>Account</MenuLabel>
<MenuItem icon={<User size={14} />} onClick={() => alert('Profile')}>Profile</MenuItem>
<MenuItem icon={<Settings size={14} />} onClick={() => alert('Settings')}>Settings</MenuItem>
<MenuSeparator />
<MenuItem icon={<LogOut size={14} />} color="danger" onClick={() => alert('Sign out')}>
Sign out
</MenuItem>
</MenuContent>
</Menu>When the menu items come from an async source, swap the items with <MenuSkeleton count={...} /> while loading. Atlaskit-inspired pattern — the skeleton matches the geometry of real items so the menu doesn't reflow when content arrives.
function UserActionsMenu() {
const { data, isLoading } = useActions();
return (
<Menu>
<MenuTrigger asChild>
<IconButton aria-label="Actions"><MoreVertical /></IconButton>
</MenuTrigger>
<MenuContent>
{isLoading ? (
<MenuSkeleton count={3} withHeading />
) : (
data.map((a) => (
<MenuItem key={a.id} icon={a.icon} onClick={() => a.run()}>
{a.label}
</MenuItem>
))
)}
</MenuContent>
</Menu>
);
}Pass selected to toggle aria-checked for toggle / multi-select patterns:
gridimport { useState } from 'react';
import { Button, Menu, MenuContent, MenuItem, MenuTrigger } from '@dashforge/tw';
function ViewPicker() {
const [view, setView] = useState<'grid' | 'list' | 'compact'>('grid');
return (
<Menu>
<MenuTrigger><Button variant="outline">View: {view} ▾</Button></MenuTrigger>
<MenuContent>
{['grid', 'list', 'compact'].map((v) => (
<MenuItem key={v} selected={view === v} onClick={() => setView(v as any)}>
{v}
</MenuItem>
))}
</MenuContent>
</Menu>
);
}For true multi-select UX (where clicking does NOT close the menu), set closeOnItemClick={false} on the parent <Menu>.
access and visibleWhen are on MenuItem (not on Menu — the menu container has no behavior to gate). Hide individual destructive actions from non-privileged users, or toggle items based on engine state:
<MenuContent>
<MenuItem onClick={onEdit}>Edit</MenuItem>
<MenuItem onClick={onArchive}>Archive</MenuItem>
<MenuSeparator />
<MenuItem
color="danger"
onClick={onDelete}
access={{ requires: 'workspace.delete', when: 'denied:hide' }}
>
Delete workspace
</MenuItem>
</MenuContent>A MenuItem that returns null (via visibleWhen or access hide) is invisible — separators around it stay intact in the visual flow.
function MyMenu() {
const [open, setOpen] = useState(false);
return (
<Menu open={open} onOpenChange={setOpen}>
<MenuTrigger asChild><Button>Toggle</Button></MenuTrigger>
<MenuContent>...</MenuContent>
</Menu>
);
}Configure <Menu> defaults application-wide.
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Menu: {
defaults: { closeOnItemClick: true, modal: false },
},
},
});Configurable axes (MenuVariantProps):
| Axis | Type | Notes |
|---|---|---|
closeOnItemClick | boolean | Auto-close on <MenuItem> click. Set false for multi-select / filter menus. |
modal | boolean | Radix modal mode — focus trap + outside-scroll lock while open. |
children and the compound sub-components (MenuTrigger, MenuContent, MenuItem, …) are structural — not theme-configurable. access / visibleWhen on MenuItem are per-instance semantics.
Precedence chain (lowest → highest):
closeOnItemClick: true, modal: false).theme.components.Menu.defaults (application-wide).<Menu closeOnItemClick={false} />) — wins over theme.TypeScript: theme.components.Menu.defaults autocompletes to the two axes above.
Reactivity: useComponentDefaults('Menu') subscribes to the theme store — patchTheme re-renders the Menu with the new axes.
Note: the documented placement and sideOffset props on <Menu> are not yet wired through to <MenuContent> — a Sprint 4.4-bis follow-up. Until then, pass side / align / sideOffset on <MenuContent> directly.
<Menu> props| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Compound children: <MenuTrigger> + <MenuContent>. |
closeOnItemClick | boolean | true | Close the menu when an item is clicked. Set to false for filter-style menus where item clicks should NOT dismiss (e.g., multi-select checkbox menus, sticky filter pills). |
defaultOpen | boolean | false | Default open state for uncontrolled mode. |
modal | boolean | false | Modal mode (Radix). When true, the menu blocks interaction with the rest of the page (focus trap + outside scroll lock). When false, dismissal is automatic on outside-click but the page remains interactive. Default false matches the "lightweight action menu" pattern. |
onOpenChange | (open: boolean) => void | — | Open/close callback (controlled). |
open | boolean | — | Controlled open state. |
placement | MenuPlacement | 'bottom-start' | Placement of the content relative to the trigger. |
sideOffset | number | 6 | Pixel offset between trigger and content along the perpendicular axis (e.g., for placement='bottom-*', the vertical gap). |
<MenuTrigger> props| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render onto child element (Radix Slot). Recommended pattern. |
children | ReactNode | (required) | The trigger element. |
<MenuContent> props| Prop | Type | Default | Description |
|---|---|---|---|
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Preferred side; auto-flips if cropped. |
align | 'start' | 'center' | 'end' | 'start' | Alignment along the chosen side. |
sideOffset | number | 4 | Distance (px) from the trigger. |
<MenuItem> props| Prop | Type | Default | Description |
|---|---|---|---|
onClick | () => void | — | Click handler. Required for interactive items. |
icon | ReactNode | — | Leading icon slot. |
endIcon | ReactNode | — | Trailing icon slot (kbd shortcut, badge, etc.). |
disabled | boolean | false | Disable the item. |
selected | boolean | false | Toggle aria-checked (for toggle/multi-select patterns). |
color | 'default' | 'danger' | 'default' | danger applies red hover bg for destructive actions. |
access | AccessSpec | — | RBAC gating. Returns null when denied. |
visibleWhen | (engine: Engine) => boolean | — | Engine-reactive predicate. Returns null when false. |
children | ReactNode | — | The item label. |
<MenuLabel> propschildren: ReactNode — non-interactive heading text.
<MenuSeparator>No props. Visual divider.
<MenuSkeleton> props| Prop | Type | Default | Description |
|---|---|---|---|
count | number | 3 | Number of placeholder rows. |
withHeading | boolean | false | Render a MenuLabel-shaped skeleton at the top. |
<Menu> is a compound API — instead of a slotProps bag, it exposes each region as a named sub-component that you compose in JSX. Style each region by passing utility classes via className / sx to the sub-component you render:
| Sub-component | Corresponds to | How to style |
|---|---|---|
<MenuTrigger> | Trigger element (usually IconButton/Button) | Style the child element you wrap. |
<MenuContent> | The floating panel | Pass className/sx on <MenuContent>. |
<MenuItem> | A single interactive row | className / sx on <MenuItem> (or via color="danger" shortcut). |
<MenuLabel> | Non-interactive heading | Style via className prop. |
<MenuSeparator> | Visual divider | Style via className. |
<MenuSkeleton> | Loading placeholder | Style via className. |
This "explicit sub-component" pattern is Menu's chosen shape over slotProps because menus are visually compositional (the consumer decides which sub-components appear and in what order) rather than a fixed anatomy like <DataGrid> or <Autocomplete>.
↑ ↓ move focus, Home / End jump to first/last, type-ahead by first letter, Esc closes, Tab returns focus to trigger.React.memo-wrapped (Atlaskit-style perf). Sibling re-renders don't re-render unchanged items.<RowActionsMenu>). We switched to @radix-ui/react-dropdown-menu because the WAI-ARIA menu pattern (roles menu / menuitem / separator, type-ahead, focus management, return-focus to trigger) is non-trivial to reimplement on a plain Popover. The Radix component adds one new peer dep but ships the full a11y story for free.access + visibleWhen on <MenuItem>, not <Menu>): the container has no behavior to gate. Putting the bridge on each item lets you mix gated and ungated items in the same menu.onDelete), pair MenuItem with <ConfirmDialog>.