Menu
A list of actions that appears when something is pressed. It nests, it holds state, and it can be driven end to end from the keyboard.
import { Button, Menu, MenuItem, MenuSeparator, MenuSubmenu } from 'neba';
<Menu trigger={<Button>Actions</Button>}>
<MenuItem shortcut="⌘E">Rename</MenuItem>
<MenuSubmenu label="Move to">
<MenuItem>Archive</MenuItem>
</MenuSubmenu>
<MenuSeparator />
<MenuItem color="danger">Delete</MenuItem>
</Menu>;Props
Menu
| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | ReactElement | — | The element that opens the menu, wired up by Base UI. Not needed for a controlled menu opened elsewhere |
| open | boolean | — | Whether it is open, for a controlled menu |
| defaultOpen | boolean | false | Whether it starts open |
| onOpenChange | (open: boolean) => void | — | Called when it opens or closes |
| sideshared | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Which edge of the trigger it hangs off. Flips when there is no room |
| alignshared | 'start' | 'center' | 'end' | 'start' | Where it sits along that edge |
| sideOffset | number | 6 | Distance from the trigger, in pixels |
| modal | boolean | true | Whether the page behind is taken away while it is open |
| openOnHover | boolean | false | Opens on hover too. For a menu bar, where crossing the row should walk through the others |
| loopFocus | boolean | true | Whether the arrow keys wrap from the last row back to the first |
| disabled | boolean | false | Unavailable. The trigger stops opening anything |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Height and type scale |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | The rows’ padding only. A menu is as wide as its longest label, so it runs a tighter ladder than Box |
| children | ReactNode | — | The rows — MenuItem, MenuGroup, MenuSeparator, MenuSubmenu and the rest |
MenuItem
| Prop | Type | Default | Description |
|---|---|---|---|
| onClick | (event) => void | — | What the row does |
| href | string | — | Passing it renders a real anchor. A menu of links has to be links, or none of them opens in a new tab |
| startIcon | ReactNode | — | Content before the label |
| endIcon | ReactNode | — | Content after the label, before any shortcut |
| shortcut | ReactNode | — | The keystroke that does the same thing, set muted at the end. Shown, never bound |
| description | ReactNode | — | A second line under the label |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | — | Re-points this row's colour family — danger for the one that deletes. Defaults to the menu's own |
| closeOnClick | boolean | true | Whether picking the row closes the menu |
| disabled | boolean | false | Unavailable. Still listed, and still found by typeahead |
| label | string | — | What typeahead matches against, when the label is not a plain string |
| children | ReactNode | — | The label |
MenuSubmenu
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | — | The label on the row that opens it |
| startIcon | ReactNode | — | Content before the label |
| sideshared | 'top' | 'right' | 'bottom' | 'left' | 'right' | Which edge of the parent row it opens against |
| sideOffset | number | 4 | Distance from the parent menu, in pixels |
| disabled | boolean | false | Unavailable |
| children | ReactNode | — | The nested rows — one of which may be another MenuSubmenu, to any depth |
ContextMenu
| Prop | Type | Default | Description |
|---|---|---|---|
| content | ReactNode | — | The rows, written exactly as they are inside a Menu |
| children * | ReactNode | — | The area that answers a right-click or a long press |
| open | boolean | — | Whether it is open |
| defaultOpen | boolean | false | Whether it starts open |
| onOpenChange | (open: boolean) => void | — | Called when it opens or closes |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Height and type scale |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | The rows’ padding only |
Examples
Groups, links and icons
Passing href turns the row into a real <a>. That is not a detail: a menu of links that are not links cannot be opened in a new tab, cannot have its address copied, and tells a screen reader the wrong thing about every row in it.
import { Button, Menu, MenuGroup, MenuItem, MenuSeparator } from 'neba';
function CopyIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect
x="5.5"
y="5.5"
width="8"
height="8"
rx="1.75"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M10.5 5.5v-1a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h1"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
);
}
function LinkIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M6.5 9.5 9.5 6.5M6 4.5 7.5 3a2.9 2.9 0 0 1 4 4L10 8.5M10 11.5 8.5 13a2.9 2.9 0 0 1-4-4L6 7.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
export default function MenuBasic() {
return (
<div className="flex flex-wrap items-start gap-3">
<Menu
trigger={
<Button variant="outline" color="secondary">
Share
</Button>
}
>
<MenuGroup label="Copy">
<MenuItem startIcon={<LinkIcon />} shortcut="⌘L">
Copy link
</MenuItem>
<MenuItem startIcon={<CopyIcon />} description="Includes the current filters">
Copy as Markdown
</MenuItem>
</MenuGroup>
<MenuSeparator />
<MenuGroup label="Open">
{/* A menu of links has to be links — `href` renders a real anchor, so
a middle click opens a tab and a right click offers to copy it. */}
<MenuItem href="https://neba.cdget.com" target="_blank">
Documentation
</MenuItem>
<MenuItem href="https://github.com/jooy2/neba" target="_blank">
Repository
</MenuItem>
</MenuGroup>
<MenuSeparator />
<MenuItem disabled>Export as PDF</MenuItem>
</Menu>
<Menu
color="danger"
trigger={
<Button variant="outline" color="danger">
Danger zone
</Button>
}
>
<MenuItem color="secondary">Archive project</MenuItem>
<MenuSeparator />
<MenuItem color="danger" description="Members lose access immediately">
Delete project
</MenuItem>
</Menu>
</div>
);
}Nested menus
There is no depth limit. A submenu's children are just menu rows, and one of them can be another MenuSubmenu. Base UI opens them on hover with a safe triangle, so reaching diagonally into an open submenu does not close it.
import { Button, Menu, MenuItem, MenuSubmenu } from 'neba';
/**
* Nesting is unlimited because a submenu's children are just menu rows, and one
* of those rows can be another submenu. Base UI opens them on hover with a safe
* triangle, so reaching diagonally into an open submenu does not close it.
*/
export default function MenuNested() {
return (
<Menu
trigger={
<Button variant="outline" color="secondary">
Insert
</Button>
}
>
<MenuItem>Text block</MenuItem>
<MenuSubmenu label="Media">
<MenuItem>Image</MenuItem>
<MenuItem>Video</MenuItem>
<MenuSubmenu label="Embed">
<MenuItem>YouTube</MenuItem>
<MenuItem>CodeSandbox</MenuItem>
<MenuSubmenu label="Other">
<MenuItem>By URL</MenuItem>
<MenuItem>By oEmbed</MenuItem>
</MenuSubmenu>
</MenuSubmenu>
</MenuSubmenu>
<MenuSubmenu label="Data">
<MenuItem>Table</MenuItem>
<MenuItem>Chart</MenuItem>
</MenuSubmenu>
</Menu>
);
}Rows that hold state
A tick says "and", a dot says "instead of" — the same distinction Checkbox and Radio make everywhere else in the library. Neither closes the menu when it is picked: a list of things to tick is a list you tick more than one of.
import { useState } from 'react';
import {
Button,
Menu,
MenuCheckboxItem,
MenuGroup,
MenuRadioGroup,
MenuRadioItem,
MenuSeparator
} from 'neba';
export default function MenuState() {
const [density, setDensity] = useState<string | number>('cosy');
const [columns, setColumns] = useState({ status: true, owner: true, updated: false });
return (
<div className="flex flex-col items-start gap-3">
<Menu
trigger={
<Button variant="outline" color="secondary">
View
</Button>
}
>
{/* A tick says "and", a dot says "instead of" — the same distinction
Checkbox and Radio make everywhere else in the library. */}
<MenuGroup label="Columns">
{(['status', 'owner', 'updated'] as const).map((key) => (
<MenuCheckboxItem
key={key}
checked={columns[key]}
onCheckedChange={(checked) => setColumns({ ...columns, [key]: checked })}
>
{key}
</MenuCheckboxItem>
))}
</MenuGroup>
<MenuSeparator />
<MenuGroup label="Density">
<MenuRadioGroup value={density} onValueChange={setDensity}>
<MenuRadioItem value="compact">Compact</MenuRadioItem>
<MenuRadioItem value="cosy">Cosy</MenuRadioItem>
<MenuRadioItem value="roomy">Roomy</MenuRadioItem>
</MenuRadioGroup>
</MenuGroup>
</Menu>
<p className="m-0 text-[0.75rem] text-(--neba-muted-fg)">
{density} ·{' '}
{Object.entries(columns)
.filter(([, on]) => on)
.map(([key]) => key)
.join(', ') || 'no columns'}
</p>
</div>
);
}Context menus
import { ContextMenu, MenuItem, MenuSeparator, MenuSubmenu } from 'neba';
/**
* The same rows, opened by a right-click or a long press instead of by a button.
* It takes the rows as `content` and the area as `children` — Tooltip's shape
* rather than Menu's, because the trigger here is a region rather than an
* element you hand over.
*/
export default function MenuContext() {
return (
<ContextMenu
content={
<>
<MenuItem shortcut="⌘X">Cut</MenuItem>
<MenuItem shortcut="⌘C">Copy</MenuItem>
<MenuItem shortcut="⌘V">Paste</MenuItem>
<MenuSeparator />
<MenuSubmenu label="Transform">
<MenuItem>To uppercase</MenuItem>
<MenuItem>To lowercase</MenuItem>
</MenuSubmenu>
<MenuSeparator />
<MenuItem color="danger">Delete</MenuItem>
</>
}
>
<div className="flex h-32 items-center justify-center rounded-(--neba-radius-md) border border-dashed border-(--n-line) text-[0.8125rem] text-(--neba-muted-fg) select-none">
Right-click anywhere in this box
</div>
</ContextMenu>
);
}Sizes
import { Button, Menu, MenuItem } from 'neba';
const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
export default function MenuSizes() {
return (
<div className="flex flex-wrap items-center gap-2">
{SIZES.map((size) => (
<Menu
key={size}
size={size}
trigger={
<Button size={size} variant="outline" color="secondary">
{size}
</Button>
}
>
<MenuItem shortcut="⌘E">Rename</MenuItem>
<MenuItem shortcut="⌘D">Duplicate</MenuItem>
<MenuItem>Move to archive</MenuItem>
</Menu>
))}
</div>
);
}The rows are code, not data
Select takes an items array. A menu does not. That is the opposite choice, and it is deliberate.
A select's options are values from a list the caller already has. A menu's rows are code — each one a different handler, a different icon, sometimes a submenu. Data would mean an items type with a variant for every shape a row can take, which is a component tree spelled as a discriminated union.
The popup is the Select popup
To the pixel. A select is a menu that remembers what you picked, and two floating lists of rows that do not match are two lists the eye has to learn separately.
The rows' padding is the one thing that differs. A List row spans a sheet that something else decided the width of; a menu row is inside a popup that is exactly as wide as its longest label. At md, Box's px-4 would add 32px to a menu that says "Cut", which is how a five-item menu ends up the width of a dialog.
What Base UI owns
Everything that makes a menu a menu rather than a floating list of divs: roving focus with the arrow keys, Home and End, typeahead, Escape, closing on an outside click, restoring focus to the trigger, submenus opening on hover with the safe triangle, and the menu / menuitem roles that make any of it mean something to a screen reader.
What is here is the surface, the ladders and the row layout.
Accessibility
- Give the row that deletes
color="danger". The whole colour family re-points, so the text, the soft background and the focus ring turn over together. - When the label is not a plain string, give
labelthe text typeahead should match against. - A
disabledrow stays listed and stays findable by typeahead. Not disappearing is the point — a row that is gone says "there is no such thing" rather than "it is not available here".