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 '@basmilius/react-ui';import { useState } from 'react';
import { Copy, Ellipsis, FolderInput, Pencil, Trash } from 'lucide-react';
import { EDIT_SHORTCUTS, Icon, IconButton, KEY_SHORTCUTS, Kbd, Menu } from '@basmilius/react-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 the box before its label drawn for you. checked and onCheckedChange. indicator="end" puts the box at the end of the row. |
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 trailing note in 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, with a chevron at its end. Put a Menu.Popup inside the SubmenuRoot. |
Placement
A menu hangs under its trigger, lined up with its start, 6 pixels away. A submenu sits beside its row, pulled up so its first row lines 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) to say otherwise. A long menu scrolls inside the room the window leaves it.
Separators take care of themselves. Two in a row, or one at the top or the bottom of a menu, are hidden. A menu built from rows that each decide whether they appear never shows a stray line.
Checks and labels
Menu.Check puts a tick in a plain row, for a list that reads as a choice but acts on click, or a row with a second line. kind="radio" is a bare tick, kind="checkbox" a tick in an outlined box, which says the row can be off.
import { useState } from 'react';
import { Button, ColorSwatch, Menu } from '@basmilius/react-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>
);
}A checkbox or radio item draws its indicator before the label. indicator="end" moves it to the end of the row, for an option that reads as a label with its control after it, such as a setting among others that keep their control at the end. The Previews row in the next demo is one.
Parts that are not rows
unstyled on Menu.Item, Menu.CheckboxItem and Menu.RadioItem leaves the row out: no padding, no highlight and no indicator, only what makes the part an item. The arrow keys still reach it, typing finds it, and picking it closes the menu unless closeOnClick={false} says otherwise. It draws its own look and its own highlight, which Base UI marks with data-highlighted and a checked item with data-checked. Use it for an icon button in a heading row, a link at the foot of a menu, a row of segments, or a ColorSwatch in a grid of colors. Menu.GroupLabel takes unstyled too, for a group that lays out its own heading.
import { useState } from 'react';
import { ChevronRight, Plus, Settings2 } from 'lucide-react';
import { Button, Icon, IconButton, Menu } from '@basmilius/react-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, as the icon button does, or give it the classes itself. A part that is no row still takes the accent outline under the keyboard from what it renders, such as IconButton, or from .focus-ring; under the pointer the theme hides that outline, as it does for a row.
Shortcuts in a row
A Kbd inside a row is pushed to the row's end in the interface font and a faint color. The shortcut is only printed there. Bind the key itself on the window, once, and let the menu row and the key call the same function.
Keyboard
Enter, Space or the down arrow on the trigger opens the menu with the first row highlighted. The arrows move, Home and End jump, and typing the start of a label finds its row. The right arrow opens a submenu and the left arrow closes it. Enter or Space picks a row, Escape closes the menu, and focus goes back to the trigger.
Under the keyboard the highlighted row carries the stronger pressed background; under the pointer a lighter hover background and no outline. See input modality.
Types
MenuPopupProps, MenuCheckProps, MenuLabelProps and MenuHintProps are exported types.