Menu
betaA list of actions or settings that opens from a button, with groups, check and radio items, and submenus.
Preview
actions
settings
links
submenu
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/menu
Use it for
- A set of actions for one thing, like Rename, Duplicate, and Delete for a file.
- One highlight glides from row to row as the pointer moves, and jumps when the arrow keys move it. Reduced motion makes it jump.
- A row of extra actions behind an icon button, when the page has no room for them.
- View settings that change the page at once: MenuCheckboxItem for on and off, MenuRadioGroup for one of a few choices.
- Use tone='danger' on an item that deletes something or cannot be undone.
- Put related items in a MenuGroup with a MenuGroupLabel, and split groups with MenuSeparator.
- A few links that go with the actions, like Settings or Billing. Use MenuLinkItem.
- The list opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container on the top MenuPopup so the list keeps its colors. A container that clips its overflow can clip the list.
Avoid it for
- A site's main navigation. Use links in a nav.
- Picking a value for a form field. Use a select.
- Fields or long text inside it. Use a popover.
- The only way to reach an action people use all the time. Show it as a button.
- Submenus more than one level deep. They are hard to use with a mouse and on phones.
Props
- Menu
Root. Holds the open state and renders no element. Takes open, defaultOpen, onOpenChange, and modal. modal is false by default, so a press outside closes the menu and still reaches the page, and one press moves from one open menu to the next. Set modal to block the page and its scroll while it is open.
{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void; modal?: boolean }
- open
Menu. Whether it is open, controlled. Use it with onOpenChange.
boolean
- defaultOpen
Menu. Whether it is open at the start, when you do not control it.
boolean · default false
- onOpenChange
Menu. Called with the new open state when it opens or closes.
(open: boolean, details) => void
- modal
Menu. Blocks the page and its scroll while it is open.
boolean · default false
- MenuTrigger
Part. The button that opens it. Pass render={<Button />} to use your own button.
ComponentProps<"button"> & { render?: ReactElement }
- MenuPopup
Part. The list. It renders in a portal while open. Use it inside MenuSubmenu too.
ComponentProps<"div">
- MenuItem
Part. One action. Clicking it, or Enter or Space, runs onClick and closes the menu.
ComponentProps<"div"> & { tone?: "neutral" | "danger"; disabled?: boolean; onClick?: () => void }
- tone
MenuItem. danger colors the item for an action that deletes or cannot be undone.
"neutral" | "danger" · default "neutral"
- disabled
MenuItem, MenuCheckboxItem, MenuRadioItem, or MenuSubmenuTrigger. Dims the item, and it cannot be chosen.
boolean · default false
- MenuLinkItem
Part. A link, as an <a>. Pass href, or render={<Link href="/settings" />} for your router's link. It has no tone and no disabled state.
ComponentProps<"a"> & { closeOnClick?: boolean; label?: string }
- closeOnClick
MenuLinkItem. Closes the menu when the link is clicked, so it does not stay open after a router changes the page.
boolean · default true
- MenuCheckboxItem
Part. Turns a setting on and off, with a check mark. The menu stays open.
ComponentProps<"div"> & { checked?: boolean; defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void }
- MenuRadioGroup
Part. Holds MenuRadioItems. Only one can be chosen.
ComponentProps<"div"> & { value?: string; defaultValue?: string; onValueChange?: (value: string) => void }
- MenuRadioItem
Part. One choice in a MenuRadioGroup, with a dot when chosen. The menu stays open.
ComponentProps<"div"> & { value: string }
- MenuGroup
Part. Groups related items.
ComponentProps<"div">
- MenuGroupLabel
Part. A small muted heading. Screen readers read it as the group's name.
ComponentProps<"div">
- MenuSeparator
Part. A line between groups. It is a top border, so a border color class recolors it.
ComponentProps<"div">
- MenuSubmenu
Root of a submenu, inside a MenuPopup. Holds a MenuSubmenuTrigger and a MenuPopup.
{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void }
- MenuSubmenuTrigger
Part. The item that opens a submenu, with an arrow at the end.
ComponentProps<"div">
- side
MenuPopup. Which side of the trigger it opens on. It flips when there is no room.
"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end" · default "bottom", or "inline-end" in a submenu
- align
MenuPopup. How it lines up with the trigger. Use end for a trigger at the end of a row.
"start" | "center" | "end" · default "start"
- collisionPadding
MenuPopup. Least gap between the list and the screen edge, in pixels. It keeps the list off the edge on phones.
number | { top?: number; right?: number; bottom?: number; left?: number } · default 8
- container
MenuPopup. The element the list opens in. By default, the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no list. A submenu follows its parent menu there, so it needs no container of its own.
HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null
- effect
MenuPopup. How the list opens and closes. rise fades in and moves out from the trigger. grow fades in and grows from the trigger, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none. A submenu opens beside its menu, so give its MenuPopup the same effect.
"rise" | "grow" · default "rise"
- className
Any part. Extra classes. Later classes win, so className='min-w-56' on MenuPopup sets its width.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| Menu | { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void; modal?: boolean } | - | Root. Holds the open state and renders no element. Takes open, defaultOpen, onOpenChange, and modal. modal is false by default, so a press outside closes the menu and still reaches the page, and one press moves from one open menu to the next. Set modal to block the page and its scroll while it is open. |
| open | boolean | - | Menu. Whether it is open, controlled. Use it with onOpenChange. |
| defaultOpen | boolean | false | Menu. Whether it is open at the start, when you do not control it. |
| onOpenChange | (open: boolean, details) => void | - | Menu. Called with the new open state when it opens or closes. |
| modal | boolean | false | Menu. Blocks the page and its scroll while it is open. |
| MenuTrigger | ComponentProps<"button"> & { render?: ReactElement } | - | Part. The button that opens it. Pass render={<Button />} to use your own button. |
| MenuPopup | ComponentProps<"div"> | - | Part. The list. It renders in a portal while open. Use it inside MenuSubmenu too. |
| MenuItem | ComponentProps<"div"> & { tone?: "neutral" | "danger"; disabled?: boolean; onClick?: () => void } | - | Part. One action. Clicking it, or Enter or Space, runs onClick and closes the menu. |
| tone | "neutral" | "danger" | "neutral" | MenuItem. danger colors the item for an action that deletes or cannot be undone. |
| disabled | boolean | false | MenuItem, MenuCheckboxItem, MenuRadioItem, or MenuSubmenuTrigger. Dims the item, and it cannot be chosen. |
| MenuLinkItem | ComponentProps<"a"> & { closeOnClick?: boolean; label?: string } | - | Part. A link, as an <a>. Pass href, or render={<Link href="/settings" />} for your router's link. It has no tone and no disabled state. |
| closeOnClick | boolean | true | MenuLinkItem. Closes the menu when the link is clicked, so it does not stay open after a router changes the page. |
| MenuCheckboxItem | ComponentProps<"div"> & { checked?: boolean; defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void } | - | Part. Turns a setting on and off, with a check mark. The menu stays open. |
| MenuRadioGroup | ComponentProps<"div"> & { value?: string; defaultValue?: string; onValueChange?: (value: string) => void } | - | Part. Holds MenuRadioItems. Only one can be chosen. |
| MenuRadioItem | ComponentProps<"div"> & { value: string } | - | Part. One choice in a MenuRadioGroup, with a dot when chosen. The menu stays open. |
| MenuGroup | ComponentProps<"div"> | - | Part. Groups related items. |
| MenuGroupLabel | ComponentProps<"div"> | - | Part. A small muted heading. Screen readers read it as the group's name. |
| MenuSeparator | ComponentProps<"div"> | - | Part. A line between groups. It is a top border, so a border color class recolors it. |
| MenuSubmenu | { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void } | - | Root of a submenu, inside a MenuPopup. Holds a MenuSubmenuTrigger and a MenuPopup. |
| MenuSubmenuTrigger | ComponentProps<"div"> | - | Part. The item that opens a submenu, with an arrow at the end. |
| side | "top" | "right" | "bottom" | "left" | "inline-start" | "inline-end" | "bottom", or "inline-end" in a submenu | MenuPopup. Which side of the trigger it opens on. It flips when there is no room. |
| align | "start" | "center" | "end" | "start" | MenuPopup. How it lines up with the trigger. Use end for a trigger at the end of a row. |
| collisionPadding | number | { top?: number; right?: number; bottom?: number; left?: number } | 8 | MenuPopup. Least gap between the list and the screen edge, in pixels. It keeps the list off the edge on phones. |
| container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | - | MenuPopup. The element the list opens in. By default, the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no list. A submenu follows its parent menu there, so it needs no container of its own. |
| effect | "rise" | "grow" | "rise" | MenuPopup. How the list opens and closes. rise fades in and moves out from the trigger. grow fades in and grows from the trigger, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none. A submenu opens beside its menu, so give its MenuPopup the same effect. |
| className | string | - | Any part. Extra classes. Later classes win, so className='min-w-56' on MenuPopup sets its width. |
Accessibility
- The trigger has aria-haspopup and aria-expanded. The list has role="menu", and items are menuitem, menuitemcheckbox, or menuitemradio.
- Enter, Space, or the down arrow on the trigger opens the menu and moves focus to the first item.
- Up and down arrows move between items, Home and End jump to the ends, and typing a letter jumps to the next item that starts with it.
- The right arrow opens a submenu, and the left arrow or Escape closes it. Escape closes the menu and puts focus back on the trigger.
- A disabled item is dimmed and has aria-disabled. Arrow keys still stop on it, so screen reader users know it is there, but it cannot be chosen.
- Check and radio items set aria-checked. The check mark and the dot are hidden from screen readers.
- A MenuLinkItem is an <a> with role="menuitem". Arrow keys reach it like any item, and Enter follows the link.
- The item under keyboard focus gets the highlight and a focus ring.
- Danger items say what they do in words, like Delete, so the color is never the only sign.
- The theme tests hold item text and group labels to 4.5:1 on the popover color, and a highlighted item to 4.5:1 on its fill, in light and dark.
- The theme tests hold a danger item's text to 4.5:1 on the popover color and on its fill, and the focus ring to 3:1 on the popover color, in light and dark.
Examples
Actions for one file (needs @txbrendan/button)
import { Button } from "@/components/ui/button";
<Menu>
<MenuTrigger render={<Button emphasis="outline" />}>File</MenuTrigger>
<MenuPopup>
<MenuItem>Rename</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuSeparator />
<MenuItem tone="danger">Delete</MenuItem>
</MenuPopup>
</Menu>View settings with check and radio items (needs @txbrendan/button)
import { Button } from "@/components/ui/button";
<Menu>
<MenuTrigger render={<Button emphasis="outline" />}>View</MenuTrigger>
<MenuPopup>
<MenuGroup>
<MenuGroupLabel>Show</MenuGroupLabel>
<MenuCheckboxItem defaultChecked>Toolbar</MenuCheckboxItem>
<MenuCheckboxItem>Status bar</MenuCheckboxItem>
</MenuGroup>
<MenuSeparator />
<MenuGroup>
<MenuGroupLabel>Sort by</MenuGroupLabel>
<MenuRadioGroup defaultValue="name">
<MenuRadioItem value="name">Name</MenuRadioItem>
<MenuRadioItem value="date">Date edited</MenuRadioItem>
</MenuRadioGroup>
</MenuGroup>
</MenuPopup>
</Menu>A submenu (needs @txbrendan/button)
import { Button } from "@/components/ui/button";
<Menu>
<MenuTrigger render={<Button emphasis="outline" />}>Share</MenuTrigger>
<MenuPopup>
<MenuItem>Copy link</MenuItem>
<MenuSubmenu>
<MenuSubmenuTrigger>Send to</MenuSubmenuTrigger>
<MenuPopup>
<MenuItem>Email</MenuItem>
<MenuItem>Text message</MenuItem>
</MenuPopup>
</MenuSubmenu>
</MenuPopup>
</Menu>Links with a router (needs @txbrendan/button)
import Link from "next/link";
import { Button } from "@/components/ui/button";
<Menu>
<MenuTrigger render={<Button emphasis="outline" />}>Account</MenuTrigger>
<MenuPopup>
<MenuLinkItem render={<Link href="/settings" />}>Settings</MenuLinkItem>
<MenuLinkItem render={<Link href="/billing" />}>Billing</MenuLinkItem>
<MenuSeparator />
<MenuItem>Sign out</MenuItem>
</MenuPopup>
</Menu>Run code when an item is chosen
"use client";
function FileMenu({ onDelete }: { onDelete: () => void }) {
return (
<Menu>
<MenuTrigger>File</MenuTrigger>
<MenuPopup>
<MenuItem tone="danger" onClick={onDelete}>
Delete
</MenuItem>
</MenuPopup>
</Menu>
);
}Grows from its trigger (needs @txbrendan/button)
import { Button } from "@/components/ui/button";
<Menu>
<MenuTrigger render={<Button emphasis="outline" />}>Sort</MenuTrigger>
<MenuPopup effect="grow">
<MenuItem>Newest first</MenuItem>
<MenuItem>Oldest first</MenuItem>
</MenuPopup>
</Menu>