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>
);
}<Menu> props| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. |
onOpenChange | (open: boolean) => void | — | Controlled open callback. |
closeOnItemClick | boolean | true | Auto-close on MenuItem click. Pass false for multi-select UX. |
children | ReactNode | (required) | MenuTrigger + MenuContent. |
<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. |
↑ ↓ 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>.