Menu
StableA short list of actions or choices that opens from a button. Reach for it when a row, a record or the page has more actions than fit on screen, or for a quick choice such as how a list is sorted.
Basics
A menu is a trigger and a sheet of rows. Wrap a Button or IconButton in MenuTrigger asChild, and give each row an onSelect: it runs the action and closes the menu. The sheet grows out of its trigger on a spring and its rows settle in just behind it. Here, the actions for one order, from the button at the end of its row.
In both themes
Held open for review: the account menu with its Appearance submenu out, and a view menu with radio and checkbox rows, light above dark. The sheet is the canvas colour in light and lifts to graphite in dark, with a faint ring so its edge holds on a dark page.
Open them for real
Each trigger opens its menu in the theme of its own side.
Rows
Every action is a MenuItem. Its icon is 16px and stays muted until the row is highlighted. hint is a quiet detail at the end, such as a file format or why a row is off. shortcut draws keycaps that read right on every platform (mod is ⌘ on a Mac and Ctrl elsewhere); it only draws them, so bind the keys in the app.
| Row | How |
|---|---|
| Critical | tone="critical" turns the row red and its highlight into a red wash. For actions that destroy something or sign out; keep them last, after a separator. |
| Disabled | disabled dims the row to 45% and the arrow keys skip it. Say why in the hint. |
| Link | asChild renders your link as the row (a router Link or an a), so it keeps link behaviour such as opening in a new tab. A trailing arrow-out hint means it leaves Orbit. |
| Keep open | Call event.preventDefault() in onSelect and the menu stays open after the row runs. |
Checkbox items
MenuCheckboxItem is a setting that toggles. The teal check draws itself in along its own path. Choosing a row closes the menu, so when people usually change several at once, keep it open with onSelect={(event) => event.preventDefault()}.
Radio groups
One choice from a few. MenuRadioGroup holds the value; each MenuRadioItem is one option, and the chosen row carries a teal satellite that drops into its ring. Show the current choice on the trigger, so nobody has to open the menu to find out.
Labels, groups and separators
MenuLabel is a quiet heading for the rows under it, in sentence case. MenuSeparator is a hairline between groups. MenuGroup ties rows together for assistive tech and has no look of its own. A view menu for the orders list mixes a radio group and checkboxes, and stays open while you change it.
Header
MenuHeader is a free-form block at the top of the sheet: who is signed in, or the record the menu acts on. It is not a row, so it cannot be focused and the arrow keys pass over it. Follow it with a separator.
Width
By default the sheet fits its rows, and is never narrower than 220px. Pass width in pixels (or any CSS length) to fix it, or 'trigger' to match the button that opened it, which suits a menu standing in for a select. Long labels truncate with an ellipsis rather than wrap.
Alignment and side
The sheet opens below its trigger (side="bottom"), 6px away, lined up with the trigger's start edge (align="start"). Use align="end" for triggers at the end of a row or the right of a header. When there is no room it flips to the other side and slides along to stay 8px inside the window.
Guidelines
- Start each row with a verb and name the thing it acts on: "Print packing slip", not "Slip".
- Keep menus short. Past about seven rows, group them under labels; past a dozen, the choice probably wants its own screen.
- One level of submenu at most, and only for a set of choices, like appearance or a status.
- A menu holds actions and quick choices. For typing, use a Popover; for a task with several fields or steps, a Dialog.
- Give every row in a group an icon, or none of them, so the labels line up.
- Name icon-only triggers for what the actions are about: "Actions for order #1043", not "More".
- Only show a shortcut when the app really binds it.
Accessibility
- Built on Radix DropdownMenu. The trigger carries
aria-haspopup="menu"andaria-expanded; rows aremenuitem,menuitemcheckboxormenuitemradiowitharia-checked. - Enter, Space or the down arrow opens the menu from the trigger and focus moves into it. The arrow keys move between rows and wrap round at the ends (
loopis on), Home and End jump, and typing a few letters jumps to the row that starts with them. Hints and keycaps are left out of that matching. - The right arrow opens a submenu and the left arrow closes it again. Escape closes the menu and hands focus back to the trigger.
- While a menu is open (
modal, the default) the page behind ignores the pointer, does not scroll, and is hidden from screen readers. - Icons are hidden from assistive tech. A hint and a shortcut are read as part of the row, with symbol keys spoken by name ("Command P").
- Highlight is a tint, never colour alone: the chosen radio and checked rows also carry a mark.
- With reduced motion the sheet and its rows fade in without growing or rising, and the check and satellite appear without moving.
Props
Every part also accepts the matching Radix DropdownMenu props.
Menu
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | None | Controlled open state. |
defaultOpen | boolean | false | Starting state when uncontrolled. |
onOpenChange | (open: boolean) => void | None | Called when the menu opens or closes. |
modal | boolean | true | While open, the rest of the page ignores the pointer, does not scroll and is hidden from screen readers. |
MenuTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render your Button or IconButton as the trigger. Almost always on. |
disabled | boolean | false | The trigger cannot open the menu. |
MenuContent
| Prop | Type | Default | Description |
|---|---|---|---|
width | number | string | 'trigger' | None | Fixed width in px, any CSS length, or 'trigger' to match the trigger. By default it fits its rows, 220px at least. |
align | 'start' | 'center' | 'end' | 'start' | Which edge of the trigger the sheet lines up with. |
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Preferred side. Flips when there is no room. |
sideOffset | number | 6 | Gap from the trigger in px. |
alignOffset | number | 0 | Nudge along the aligned edge in px. |
collisionPadding | number | 8 | Space kept from the edges of the window. |
loop | boolean | true | The arrow keys wrap round from the last row to the first. |
container | HTMLElement | null | document.body | Where the sheet is portalled. |
onCloseAutoFocus | (event: Event) => void | None | Runs as focus goes back to the trigger. Prevent it to send focus elsewhere. |
MenuItem
| Prop | Type | Default | Description |
|---|---|---|---|
childrenRequired | ReactNode | None | The label. |
onSelect | (event: Event) => void | None | Runs the action. The menu closes after it unless you call event.preventDefault(). |
icon | ReactNode | None | Leading 16px icon, muted until the row is highlighted. |
hint | ReactNode | None | Muted detail at the end, such as a format or why a row is disabled. |
shortcut | string[] | None | Keycaps via Kbd, e.g. ['mod', 'shift', 'C']. Drawn only; bind the keys yourself. |
tone | 'critical' | None | Red, for actions that destroy something or sign out. |
disabled | boolean | false | Dimmed and skipped by the arrow keys. |
asChild | boolean | false | Render a link (router Link or a) as the row. |
textValue | string | None | Text for typing to jump to the row. Defaults to the label. |
MenuCheckboxItem
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | None | Controlled state. |
onCheckedChange | (checked: boolean) => void | None | Called with the new state. |
onSelect | (event: Event) => void | None | Prevent the event to keep the menu open. |
icon, hint, shortcut | ReactNode, ReactNode, string[] | None | As on MenuItem. The check sits at the end. |
disabled | boolean | false | Dimmed and skipped by the arrow keys. |
MenuRadioGroup
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | None | The chosen value. |
onValueChange | (value: string) => void | None | Called with the newly chosen value. |
MenuRadioItem
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | string | None | The value this row stands for. |
onSelect | (event: Event) => void | None | Prevent the event to keep the menu open. |
icon, hint, shortcut | ReactNode, ReactNode, string[] | None | As on MenuItem. The ring and satellite sit at the end. |
disabled | boolean | false | Dimmed and skipped by the arrow keys. |
MenuSub
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | None | Controlled open state of the submenu. |
defaultOpen | boolean | false | Starting state when uncontrolled. |
onOpenChange | (open: boolean) => void | None | Called when the submenu opens or closes. |
MenuSubTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
childrenRequired | ReactNode | None | The label. |
icon | ReactNode | None | Leading 16px icon. |
hint | ReactNode | None | Muted detail before the chevron: the current choice inside. |
disabled | boolean | false | The submenu cannot open. |
MenuSubContent
| Prop | Type | Default | Description |
|---|---|---|---|
width | number | string | 'trigger' | None | As on MenuContent. |
sideOffset | number | 10 | Measured from the trigger row, so the sheet sits 4px clear of its parent. |
alignOffset | number | -6 | Lines the first row up with the trigger row. |
collisionPadding | number | 8 | Space kept from the edges of the window. |
loop | boolean | true | The arrow keys wrap round. |
container | HTMLElement | null | document.body | Where the sheet is portalled. |
MenuLabel
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | None | A quiet heading for the rows below it, in sentence case. Not focusable. |
MenuGroup
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | None | The rows it groups for assistive tech. It has no look of its own. |
MenuSeparator
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | None | A hairline between groups. It has no props of its own beyond the usual element attributes. |
MenuHeader
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | None | Free-form content, laid out in a row with a 10px gap. Accepts every div attribute. |