Menu
A list of actions that appears when a trigger is pressed. It can hold nested submenus and checkable rows, and is fully operable 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>;Rows are written as components rather than passed as an array, because each carries its own handler and icon and some of them are submenus. For a list that picks a value, use Select.
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 |
| target | string | — | Where the link opens. Ignored without href. Anything other than this tab also gets noopener noreferrer added to rel |
| rel | string | — | The link's rel. Merged rather than replaced, so writing nofollow does not take the protection off a link that still opens elsewhere |
| 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 |
| disabled | boolean | — | Stops it answering a right-click or a long press |
| loopFocus | boolean | true | Whether the arrow keys wrap from the last row back to the first |
| 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
href · startIcon · shortcut
href renders the row as a real <a>, so it can be opened in a new tab or have its address copied. shortcut takes a Shortcut. MenuSeparator divides rows into groups.
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>
);
}MenuSubmenu
There is no depth limit: a submenu's children are menu rows too, so one of them can be another MenuSubmenu. They open on hover, and moving the pointer 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>
);
}Checkable and radio rows
A tick is for items that can be on together; a dot is for one-of-a-set. Neither closes the menu when picked. closeOnClick can be set per row.
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>
);
}ContextMenu
A menu opened by right-click. Pass the rows as content and the target area as children.
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>
);
}size and density
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>
);
}side · align · openOnHover
side and align place the popup relative to the trigger. openOnHover opens the menu without a click.
Accessibility
- The
menu/menuitemroles, roving focus with the arrow keys, Home and End, typeahead, Escape, closing on an outside click and restoring focus to the trigger are all handled. - Give a destructive row
color="danger"; the text, the soft background and the focus ring turn over together. - When the label is not a string, give
labelthe text typeahead should match against. - A
disabledrow stays listed and findable by typeahead. A row that disappears reads as "there is no such thing" rather than "it is not available here".