Menu
A dropdown menu of actions, as a compound component on Base UI's menu. Every part takes Base UI's own props; the library adds the look, the portal and the placement.
import { Menu } from '@adecore/ui';import { useState } from 'react';
import { Copy, Ellipsis, FolderInput, Pencil, Trash } from 'lucide-react';
import { EDIT_SHORTCUTS, Icon, IconButton, KEY_SHORTCUTS, Kbd, Menu } from '@adecore/ui';
export default function MenuDemo() {
const [wrap, setWrap] = useState(true);
const [sort, setSort] = useState('name');
return (
<Menu.Root>
<IconButton icon={Ellipsis} label="More" render={<Menu.Trigger />} />
<Menu.Popup>
<Menu.Item>
<Icon icon={Pencil} size={14} /> Rename <Kbd shortcut={KEY_SHORTCUTS.rename} />
</Menu.Item>
<Menu.Item>
<Icon icon={Copy} size={14} /> Copy path <Kbd shortcut={EDIT_SHORTCUTS.copy} />
</Menu.Item>
<Menu.SubmenuRoot>
<Menu.SubmenuTrigger>
<Icon icon={FolderInput} size={14} /> Move to
</Menu.SubmenuTrigger>
<Menu.Popup>
<Menu.Item>Documents</Menu.Item>
<Menu.Item>
Archive <Menu.Hint>Read only</Menu.Hint>
</Menu.Item>
<Menu.Item disabled>Shared</Menu.Item>
</Menu.Popup>
</Menu.SubmenuRoot>
<Menu.Separator />
<Menu.CheckboxItem checked={wrap} onCheckedChange={setWrap}>
Wrap lines
</Menu.CheckboxItem>
<Menu.Group>
<Menu.GroupLabel>Sort by</Menu.GroupLabel>
<Menu.RadioGroup value={sort} onValueChange={setSort}>
<Menu.RadioItem value="name">Name</Menu.RadioItem>
<Menu.RadioItem value="modified">Last modified</Menu.RadioItem>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item>
<Icon icon={Trash} size={14} /> Delete
</Menu.Item>
</Menu.Popup>
</Menu.Root>
);
}Parts
| Part | What it is |
|---|---|
Menu.Root | Holds the open state. Takes open, onOpenChange and defaultOpen, or keeps the state itself. |
Menu.Trigger | The button that opens the menu, usually handed to an IconButton or a Button through render. |
Menu.Popup | The portal, the positioner and the popup in one part. |
Menu.Item | A row that acts. onClick runs it, disabled grays it out, and closeOnClick={false} keeps the menu open. unstyled makes it an item that is no row (below). |
Menu.CheckboxItem | A row that is on or off, with checked and onCheckedChange. Draws its box before the label, or at the end with indicator="end". |
Menu.RadioGroup, Menu.RadioItem | One of several. The group takes value and onValueChange, each item a value. indicator="end" puts the tick at the end of the row. |
Menu.Group, Menu.GroupLabel | A group of rows under a label a screen reader reads as the group's name. |
Menu.Label | A label above rows that are not a group of their own, such as a row of swatches. |
Menu.Separator | The hairline between groups. |
Menu.Hint | A quiet note at the end of a row about what the item does. A shortcut is a Kbd instead. |
Menu.Check | The tick slot of a row that shows a state but is not a checkbox or radio item. |
Menu.SubmenuRoot, Menu.SubmenuTrigger | A row that opens another menu beside it. Put a Menu.Popup inside the SubmenuRoot. The trigger ends in a chevron; chevron={false} leaves it off for a row that ends in a hint. |
Menu.Row, Menu.RowAction, Menu.RowSubmenuTrigger | A row with buttons at its end (below). |
Placement
A menu opens under its trigger, start-aligned, 6 pixels away and at least 200 pixels wide. A submenu lines its first row up with the row that opened it. Menu.Popup takes Base UI's placement props (side, align, sideOffset, alignOffset, collisionPadding, collisionBoundary, collisionAvoidance, anchor, sticky, positionMethod). A long menu scrolls.
Doubled separators, and separators at the top or bottom, are hidden, so conditional rows never leave a stray line.
Checks and labels
Menu.Check puts a tick in a plain row, for a choice that acts on click or a row with a second line. kind="radio" is a bare tick; kind="checkbox" boxes it, saying the row can be off.
import { useState } from 'react';
import { Button, ColorSwatch, Menu } from '@adecore/ui';
const LAYOUTS = ['Columns', 'Rows', 'Grid'];
const COLORS = ['#2563eb', '#7c3aed', '#e11d48', '#16a34a'];
export default function MenuCheckDemo() {
const [layout, setLayout] = useState('Columns');
const [color, setColor] = useState(COLORS[0]);
return (
<Menu.Root>
<Menu.Trigger render={<Button variant="secondary" />}>Layout</Menu.Trigger>
<Menu.Popup align="center">
{LAYOUTS.map((entry) => (
<Menu.Item key={entry} closeOnClick={false} onClick={() => setLayout(entry)}>
<Menu.Check kind="radio" checked={layout === entry} />
{entry}
</Menu.Item>
))}
<Menu.Separator />
<Menu.Label>Color</Menu.Label>
<div className="flex gap-2 px-2.5 py-1.5">
{COLORS.map((swatch) => (
<ColorSwatch
key={swatch}
render={<Menu.Item unstyled closeOnClick={false} />}
aria-label={swatch}
color={swatch}
picked={color === swatch}
on="popup"
onClick={() => setColor(swatch)}
/>
))}
</div>
</Menu.Popup>
</Menu.Root>
);
}Parts that are not rows
unstyled on Menu.Item, Menu.CheckboxItem and Menu.RadioItem drops the row's padding, highlight and indicator. The part stays an item: arrow keys and typeahead reach it, and picking it closes the menu unless closeOnClick={false}. Style it yourself on data-highlighted and data-checked. Use it for an icon button in a heading row, a link at the foot, or a ColorSwatch in a grid. Menu.GroupLabel takes unstyled too.
import { useState } from 'react';
import { ChevronRight, Plus, Settings2 } from 'lucide-react';
import { Button, Icon, IconButton, Menu } from '@adecore/ui';
const DENSITIES = ['Compact', 'Normal', 'Roomy'];
export default function MenuUnstyledDemo() {
const [density, setDensity] = useState('Normal');
const [previews, setPreviews] = useState(true);
return (
<Menu.Root>
<Menu.Trigger render={<Button variant="secondary" />}>
<Icon icon={Settings2} size={14} /> View
</Menu.Trigger>
<Menu.Popup className="w-72">
<div className="flex items-center gap-1 py-0.5 pr-0.5 pl-2.5 text-sm text-text-muted">
<span className="grow">Lists</span>
<IconButton icon={Plus} size="xs" label="Add a list" render={<Menu.Item unstyled />} />
</div>
<Menu.Separator />
<div className="flex items-center gap-2.5 px-2.5 py-1.5 text-sm text-text-muted">
<span className="grow">Density</span>
<Menu.RadioGroup
value={density}
onValueChange={setDensity}
aria-label="Density"
className="flex gap-0.5 rounded-md bg-surface-sunken p-0.5"
>
{DENSITIES.map((entry) => (
<Menu.RadioItem
key={entry}
unstyled
value={entry}
closeOnClick={false}
className="focus-ring flex h-6 items-center rounded px-2 text-xs data-checked:bg-surface-active data-checked:text-text data-highlighted:text-text"
>
{entry}
</Menu.RadioItem>
))}
</Menu.RadioGroup>
</div>
<Menu.CheckboxItem checked={previews} onCheckedChange={setPreviews} closeOnClick={false} indicator="end" className="text-text-muted">
<span className="grow">Previews</span>
</Menu.CheckboxItem>
<div className="flex justify-end px-2.5 py-1.5 text-xs">
<Menu.Item unstyled className="focus-ring flex items-center gap-0.5 rounded font-medium text-accent data-highlighted:underline">
All settings <Icon icon={ChevronRight} size={12} />
</Menu.Item>
</div>
</Menu.Popup>
</Menu.Root>
);
}Hand the part to another component through render, or give it classes. Its keyboard outline comes from what it renders, such as IconButton, or from .focus-ring.
A row with buttons
Menu.Row puts buttons at the end of a row, and the row highlights as one. Its first child is the row itself, a Menu.Item or a Menu.SubmenuTrigger. Name the group with aria-label, usually the row's label.
Menu.RowAction acts at once. It takes an icon, a label (accessible name and tooltip) and onClick. closeOnClick={false} keeps the menu open. iconClassName reaches the icon. Menu.RowSubmenuTrigger looks the same and opens a submenu; put it in a Menu.SubmenuRoot with a Menu.Popup. It has no tooltip.
import { useState } from 'react';
import { Circle, Ellipsis, ListTodo, Pencil, Play, Square, Trash } from 'lucide-react';
import { Button, Icon, Menu } from '@adecore/ui';
const TASKS = ['Dev server', 'Tests', 'Docs'];
export default function MenuRowDemo() {
const [running, setRunning] = useState<string[]>(['Dev server']);
const [message, setMessage] = useState('');
const toggle = (task: string) => setRunning((tasks) => (tasks.includes(task) ? tasks.filter((entry) => entry !== task) : [...tasks, task]));
return (
<div className="flex flex-col items-start gap-3">
<Menu.Root>
<Menu.Trigger render={<Button variant="secondary" />}>
<Icon icon={ListTodo} size={14} /> Tasks
</Menu.Trigger>
<Menu.Popup className="min-w-64">
{TASKS.map((task) => {
const live = running.includes(task);
return (
<Menu.Row key={task} aria-label={task}>
<Menu.Item onClick={() => setMessage(`Show the output of ${task}`)}>
<Icon icon={Circle} size={12} className={live ? 'fill-current text-status-running' : 'text-text-faint'} /> {task}
</Menu.Item>
<Menu.RowAction
icon={live ? Square : Play}
label={live ? `Stop ${task}` : `Start ${task}`}
iconClassName="fill-current"
closeOnClick={false}
onClick={() => toggle(task)}
/>
<Menu.SubmenuRoot>
<Menu.RowSubmenuTrigger icon={Ellipsis} label={`More for ${task}`} />
<Menu.Popup>
<Menu.Item onClick={() => setMessage(`Edit ${task}`)}>
<Icon icon={Pencil} size={14} /> Edit
</Menu.Item>
<Menu.Item onClick={() => setMessage(`Remove ${task}`)}>
<Icon icon={Trash} size={14} /> Remove
</Menu.Item>
</Menu.Popup>
</Menu.SubmenuRoot>
</Menu.Row>
);
})}
</Menu.Popup>
</Menu.Root>
<span role="status" className="text-xs text-text-muted">
{message || `${running.length} of ${TASKS.length} running.`}
</span>
</div>
);
}The arrow keys reach each button after its row. Typeahead reads only the row's label.
Shortcuts in a row
A Kbd in a row sits at its end, faint. It only prints the shortcut; bind the key once outside the menu and call the same function.
Keyboard
| Key | Action |
|---|---|
| Arrow up, Arrow down | Move |
| Home, End | Jump to the first or last row |
| Typing | Find a row by its label |
| Arrow right, Arrow left | Open or close a submenu |
| Enter, Space | Pick the row |
| Escape | Close and focus the trigger |
See input modality for how the highlight differs under keyboard and pointer.
Types
MenuPopupProps, MenuCheckProps, MenuLabelProps, MenuHintProps, MenuRowProps, MenuRowActionProps and MenuRowSubmenuTriggerProps are exported types.