On this page
MenuExample.tsx
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/Menu";
export function MenuExample() {
return (
<Menu>
<MenuTrigger iconEnd="chevron-down">Project actions</MenuTrigger>
<MenuPopup>
<MenuItem icon="copy">Copy link</MenuItem>
<MenuItem>Download</MenuItem>
<MenuItem disabled>Delete</MenuItem>
</MenuPopup>
</Menu>
);
}Examples
Actions
Last action: nothing yet.
Icons and links
Last action: nothing yet.
Radio group (controlled)
Sorted by newest.
Checkboxes and a separator
Descriptions shown; archived hidden.
Space toggles an option and keeps the menu open; Enter or a click closes it.
Trigger looks
Placement
Controlled open
The menu is closed.
Customized with component tokens
CSS
.compactMenu {
--component-menu-item-font-size: var(--type-size-small);
--component-menu-item-checked-indicator: var(--color-accent-2-text);
}Props
Any native prop not listed here goes to the root element unchanged.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state; pair with onOpenChange. |
onOpenChange | (open: boolean) => void | — | Called on every requested open or close, controlled or not. An outside press, Escape and Tab always request closing. |
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "primary" | "secondary" | "ghost" | "secondary" | Emphasis: one primary action per view, secondary for the rest, ghost for toolbars. |
size | "sm" | "md" | "md" | 32px or 40px tall. |
iconStart | IconName | — | Decorative icon before the label. |
iconEnd | IconName | — | Decorative icon after the label. |
type | "button" | "submit" | "reset" | "button" | Defaults to "button", not the native "submit". |
disabled | boolean | false | Sets aria-disabled, not the native attribute: still focusable, announced, and never activated. |
aria-pressed | boolean | "mixed" | — | Makes it a toggle button. "mixed" has no look of its own. |
disabledReason | ReactNode | — | While disabled: why. It's the button's accessible description, and shows as a tooltip on hover, keyboard focus, and a press or tap. |
nameTooltip | boolean | true | Icon-only buttons named by aria-label or VisuallyHidden show that name as a tooltip; false turns it off. |
| Prop | Type | Default | Description |
|---|---|---|---|
side | "bottom" | "top" | "bottom" | The side of the trigger it opens on. It still flips when it doesn't fit. |
align | "start" | "end" | "start" | The trigger edge it lines up with. It still shifts to stay in the viewport. |
| Prop | Type | Default | Description |
|---|---|---|---|
onSelect | () => void | — | Runs on activation, before the menu closes (and before a link is followed). |
disabled | boolean | false | Shown and announced as unavailable; focusable but never activated. |
icon | IconName | — | Decorative icon before the label. |
closeOnSelect | boolean | — | true always closes the menu on choosing this item, false never does. By default Enter and click close; Space closes only on action and link items. |
href | string | — | Makes it a link item (<a role="menuitem">). A /path routes client-side; modifier-clicks work as on any link. |
newTab | boolean | false | With href: opens in a new tab, with an icon and “, opens in new tab” in its name. |
rel | string | — | With href: merged with noopener noreferrer for newTab. |
| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | — | Controlled state; pair with onCheckedChange. |
defaultChecked | boolean | false | Uncontrolled initial state. |
onCheckedChange | (checked: boolean) => void | — | Called with the new state. |
icon | IconName | — | Decorative icon before the label. |
disabled | boolean | false | Shown and announced as unavailable; focusable but never activated. |
closeOnSelect | boolean | — | true always closes the menu on choosing this item, false never does. By default Enter and click close; Space closes only on action and link items. |
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Required. The group's accessible name, shown small. |
value | string | — | Controlled value; pair with onValueChange. |
defaultValue | string | — | Uncontrolled initial value. |
onValueChange | (value: string) => void | — | Called with the chosen value. |
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Required. The value this item selects. |
icon | IconName | — | Decorative icon before the label. |
disabled | boolean | false | Shown and announced as unavailable; focusable but never activated. |
closeOnSelect | boolean | — | true always closes the menu on choosing this item, false never does. By default Enter and click close; Space closes only on action and link items. |
Component tokens
| Token | Copy | Description | Aliases | Light | Dark | Preview |
|---|---|---|---|---|---|---|
--component-menu-popup-background | Popup surface. | color.surface.raised | #fffffffrom color.gray.0 | #18202efrom color.slate.900 | ||
--component-menu-popup-border | Popup outline. | color.border.subtle | #e5dccbfrom color.sand.200 | #272f3ffrom color.slate.800 | ||
--component-menu-popup-radius | Popup corners. | radius.surface | 4pxfrom radius.4 | 4pxfrom radius.4 | ||
--component-menu-popup-elevation | Popup shadow, so it reads above the page. | effect.elevation.overlay | 0px 4px 12px 0px rgb(0% 0% 0% / 0.12)from effect.shadow.md | 0px 4px 12px 0px rgb(0% 0% 0% / 0.4)from effect.shadow.md-dark | ||
--component-menu-popup-padding | Padding around the items. | space.inset.sm | 0.5remfrom space.2 | 0.5remfrom space.2 | ||
--component-menu-popup-min-width | Minimum popup width. | size.column.sm | 12remfrom size.48 | 12remfrom size.48 | ||
--component-menu-popup-border-width | Popup outline width. | effect.border.width | 2pxfrom effect.line.2 | 2pxfrom effect.line.2 | ||
--component-menu-popup-offset | Gap between the popup and its trigger (and the viewport edge in the JS fallback). | space.stack.sm | 0.5remfrom space.2 | 0.5remfrom space.2 | ||
--component-menu-popup-slide-distance | How far the popup slides in from its trigger's side as it opens. | space.stack.xs | 0.25remfrom space.1 | 0.25remfrom space.1 | ||
--component-menu-item-text | Item text. | color.text.default | #17202bfrom color.gray.900 | #fffffffrom color.gray.0 | ||
--component-menu-item-background-focus | Item background while focused (pointing at an item focuses it). | color.surface.sunken | #f2ede2from color.sand.100 | #060a13from color.slate.1000 | ||
--component-menu-item-text-focus | Item text while focused. | color.text.default | #17202bfrom color.gray.900 | #fffffffrom color.gray.0 | ||
--component-menu-item-focus-ring-color | Focus indicator, drawn inside the item. | color.focus.ring | #005f7bfrom color.cyan.700 | #54d8f4from color.cyan.300 | ||
--component-menu-item-padding-inline | Inline padding of an item. | space.inline.sm | 0.5remfrom space.2 | 0.5remfrom space.2 | ||
--component-menu-item-height | Minimum block size of an item (32px, above the 24px target minimum). | size.control.sm | 2remfrom size.8 | 2remfrom size.8 | ||
--component-menu-item-radius | Item corners. | radius.control | 4pxfrom radius.4 | 4pxfrom radius.4 | ||
--component-menu-item-gap | Gap between an item's icons and label. | space.inline.sm | 0.5remfrom space.2 | 0.5remfrom space.2 | ||
--component-menu-item-checked-indicator | Check mark of a checked radio or checkbox item (3:1, it carries state). | color.action.background | #b37500from color.gold.600 | #f5e3c2from color.champagne.100 | ||
--component-menu-item-disabled-text | Text of a disabled item. | color.text.subtle | #5d6876from color.gray.600 | #d3dce6from color.slate.200 | ||
--component-menu-item-font-family | Item font, so items don't inherit the text the Menu sits in. | type.family.body | 'Inter Variable', 'Inter Fallback', system-ui, -apple-system, 'Segoe UI', Roboto, sans-seriffrom type.font-stack.sans | 'Inter Variable', 'Inter Fallback', system-ui, -apple-system, 'Segoe UI', Roboto, sans-seriffrom type.font-stack.sans | ||
--component-menu-item-font-size | Item font size. | type.size.body | 1remfrom type.font-size.md | 1remfrom type.font-size.md | ||
--component-menu-item-line-height | Item line height. | type.leading.body | 1.6from type.line-height.normal | 1.6from type.line-height.normal | ||
--component-menu-item-font-weight | Item font weight. | type.weight.body | 400from type.font-weight.400 | 400from type.font-weight.400 | ||
--component-menu-group-gap | Space between a group and the items or groups beside it. | space.stack.sm | 0.5remfrom space.2 | 0.5remfrom space.2 | ||
--component-menu-group-label-text | Radio group label. | color.text.subtle | #5d6876from color.gray.600 | #d3dce6from color.slate.200 | ||
--component-menu-group-label-font-family | Radio group label font (mono). | type.family.label | 'Fira Code Variable', 'Fira Code Fallback', ui-monospace, SFMono-Regular, Menlo, Consolas, monospacefrom type.font-stack.mono | 'Fira Code Variable', 'Fira Code Fallback', ui-monospace, SFMono-Regular, Menlo, Consolas, monospacefrom type.font-stack.mono | ||
--component-menu-group-label-font-size | Radio group label size. | type.size.label | 0.75remfrom type.font-size.xs | 0.75remfrom type.font-size.xs | ||
--component-menu-group-label-tracking | Radio group label letter spacing. | type.tracking.label | 0.16emfrom type.letter-spacing.16 | 0.16emfrom type.letter-spacing.16 | ||
--component-menu-group-label-padding-block | Block padding of a group label. | space.stack.xs | 0.25remfrom space.1 | 0.25remfrom space.1 | ||
--component-menu-separator-color | Separator line (decorative). | color.border.subtle | #e5dccbfrom color.sand.200 | #272f3ffrom color.slate.800 | ||
--component-menu-separator-thickness | Separator line thickness. | effect.border.width | 2pxfrom effect.line.2 | 2pxfrom effect.line.2 | ||
--component-menu-separator-margin-block | Space above and below a separator. | space.stack.xs | 0.25remfrom space.1 | 0.25remfrom space.1 | ||
--component-menu-transition-duration | Duration of the popup's fade (0ms under reduced motion). | motion.duration.fast | 150msfrom motion.time.150Reduced motion: 0ms | 150msfrom motion.time.150 | No preview | |
--component-menu-transition-easing | Easing of the popup's fade. | motion.easing.enter | cubic-bezier(0, 0, 0.2, 1)from motion.curve.out | cubic-bezier(0, 0, 0.2, 1)from motion.curve.out | No preview |
Accessibility
- WAI-ARIA menu button: the trigger has aria-haspopup="menu" and aria-expanded; the popup is role="menu", named by the trigger.
- Pointing at an item focuses it, so only one item is ever highlighted, and the keyboard continues from there.
- Checkbox items are menuitemcheckbox and radio items menuitemradio, each with aria-checked and a visible check mark; radio items sit in a group named by its label.
- Separators are role="separator" and the keyboard skips them.
- Link items are real links (<a role="menuitem">): new tabs and modifier-clicks work, and a new-tab item's name ends “, opens in new tab”.
- Only the focused item is in the tab sequence; disabled items stay focusable but never activate.
- A controlled menu opens and closes through onOpenChange; an outside press, Escape and Tab always request closing.
- The focus ring is component.menu.item.focus-ring-color, drawn inside the item.
- In forced colors, the focused item uses Highlight and a disabled item GrayText; a focused disabled item keeps GrayText and gets a Highlight ring instead.
- The popup is in the top layer, so overflow can't clip it, and it flips to stay in the viewport.
| Key | Action |
|---|---|
| Enter / Space / click | On the trigger: opens, focusing the checked item or the first. |
| Down Arrow / Up Arrow | On the trigger: opens on the first or last item. In the menu: next or previous item, wrapping. |
| Home / End | First or last item. |
| A to Z | Next item starting with that letter. |
| Enter | Chooses the item (or follows the link), closes, and returns focus to the trigger. |
| Space | Chooses an action or link item and closes. Toggles a checkbox or radio item and keeps the menu open. |
| Escape | Closes and returns focus to the trigger. |
| Tab | Closes and moves focus on as usual. |