Skip to content
txbrendan/ui

Menu

beta

A list of actions or settings that opens from a button, with groups, check and radio items, and submenus.

Preview

Effect

actions

settings

links

submenu

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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>