# Menu

A selector plate: items engraved into brushed metal, the highlighted one pressed in, a lamp for checked settings, engraved rules between groups and keycaps for shortcuts. Dropdown, submenu and context menu.

- Category: Essentials
- Docs: https://bevel-ui.vercel.app/components/menu
- Import from: `@/components/ui/menu`

## Install

```bash
npx shadcn@latest add @bevel/menu
```

npm dependencies (installed by the CLI): `@base-ui/react`, `cn`.

## Anatomy

```tsx
<Menu>
  <MenuTrigger />
  <MenuContent>
    <MenuItem>
      Duplicate
      <MenuShortcut keys={["⌘", "D"]} />
    </MenuItem>
    <MenuLinkItem href="/page.md" />
    <MenuSub>
      <MenuSubTrigger />
      <MenuSubContent>
        <MenuItem />
      </MenuSubContent>
    </MenuSub>
    <MenuSeparator />
    <MenuCheckboxItem />
    <MenuGroup>
      <MenuGroupLabel />
      <MenuRadioGroup>
        <MenuRadioItem />
      </MenuRadioGroup>
    </MenuGroup>
  </MenuContent>
</Menu>
```

## Parts

| Part | Renders | Description |
| --- | --- | --- |
| `Menu` | Menu root | open, defaultOpen, onOpenChange, modal. |
| `MenuTrigger` | button | Opens the plate. Pass render to use your own button. |
| `MenuContent` | Menu popup | The plate. side (default bottom), align (default start), sideOffset, alignOffset, collisionPadding. Scrolls when tall. |
| `MenuItem` | div (menuitem) | Pressed in while highlighted. onClick runs it. variant="destructive" engraves it in red. Icons are muted and sized for you. |
| `MenuLinkItem` | a (menuitem) | An item that is a real link: href, target. |
| `MenuCheckboxItem` | div (menuitemcheckbox) | A pill lamp, lit when checked. checked, onCheckedChange. Stays open when toggled. |
| `MenuRadioGroup` | div (group) | value, onValueChange. |
| `MenuRadioItem` | div (menuitemradio) | A round lamp, lit when chosen. value. |
| `MenuGroup` | div (group) | Groups items under a label. |
| `MenuGroupLabel` | div | A small stamped heading. |
| `MenuSeparator` | div (separator) | An engraved rule. |
| `MenuShortcut` | span | keys: one keycap per key. Hidden from screen readers; put aria-keyshortcuts on the item. |
| `MenuSub` | Submenu root | Holds a submenu. |
| `MenuSubTrigger` | div (menuitem) | Opens the submenu on hover, → or Enter. Has a chevron. |
| `MenuSubContent` | Menu popup | The submenu's plate, beside its trigger. |
| `ContextMenu` | Context menu root | Opens on right-click or long-press. |
| `ContextMenuTrigger` | div | The area that opens it. |
| `ContextMenuContent` | Menu popup | The plate at the pointer. Fill it with the same items. |

## Example: Copy page

The menu on a docs page with a Markdown version: copy it, view it, or open it in ChatGPT or Claude. Pass markdownUrl.

```tsx
"use client"

import * as React from "react"
import { Check, ChevronDown, Copy, ExternalLink, FileText } from "lucide-react"
import {
  Menu,
  MenuContent,
  MenuItem,
  MenuLinkItem,
  MenuSeparator,
  MenuTrigger,
} from "@/components/ui/menu"
import { IconKey } from "@/components/ui/icon-key"
import { Key } from "@/components/ui/key"

type CopyPageMenuProps = {
  /** The page as Markdown, e.g. "/components/menu.md". Relative URLs resolve against the current page. */
  markdownUrl?: string
}

const prompt = (url: string) => `Read ${url} so I can ask questions about it.`

/**
 * Copies the page as Markdown into the clipboard. Safari only lets a page
 * write to the clipboard during the click, so the text goes in as a promise
 * where ClipboardItem exists; elsewhere it's fetched first.
 */
async function copyMarkdown(url: string) {
  const text = () =>
    fetch(url).then((response) => {
      if (!response.ok) throw new Error(`${response.status} fetching ${url}`)
      return response.text()
    })
  if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
    const blob = text().then((t) => new Blob([t], { type: "text/plain" }))
    await navigator.clipboard.write([new ClipboardItem({ "text/plain": blob })])
  } else {
    await navigator.clipboard.writeText(await text())
  }
}

/** The "Copy page" menu for a docs page that has a Markdown version. */
export function MenuCopyPage({
  markdownUrl = "/components/menu.md",
}: CopyPageMenuProps) {
  const [state, setState] = React.useState<"idle" | "copied" | "failed">("idle")
  // Assistants need an absolute address; resolve it once mounted.
  const [absolute, setAbsolute] = React.useState(markdownUrl)
  const timer = React.useRef<number | undefined>(undefined)

  React.useEffect(
    () => setAbsolute(new URL(markdownUrl, window.location.href).href),
    [markdownUrl]
  )
  React.useEffect(() => () => window.clearTimeout(timer.current), [])

  function copy() {
    copyMarkdown(markdownUrl).then(
      () => setState("copied"),
      () => setState("failed")
    )
    window.clearTimeout(timer.current)
    timer.current = window.setTimeout(() => setState("idle"), 2000)
  }

  return (
    // A split key: the label does what it says, the chevron holds the other ways to read the page.
    <div className="flex items-center gap-1">
      <Key variant="secondary" size="sm" onClick={copy}>
        {state === "copied" ? <Check aria-hidden /> : <Copy aria-hidden />}
        {state === "copied" ? "Copied" : "Copy page"}
      </Key>
      <Menu>
        <MenuTrigger
          render={
            <IconKey
              variant="secondary"
              size="sm"
              aria-label="More ways to read this page"
            />
          }
        >
          <ChevronDown aria-hidden />
        </MenuTrigger>
        <MenuContent align="end">
          <MenuItem onClick={copy}>
            <Copy aria-hidden />
            Copy as Markdown
          </MenuItem>
          <MenuLinkItem href={markdownUrl} target="_blank" rel="noopener">
            <FileText aria-hidden />
            View as Markdown
          </MenuLinkItem>
          <MenuSeparator />
          <MenuLinkItem
            href={`https://chatgpt.com/?hints=search&q=${encodeURIComponent(prompt(absolute))}`}
            target="_blank"
            rel="noopener"
          >
            <ExternalLink aria-hidden />
            Open in ChatGPT
          </MenuLinkItem>
          <MenuLinkItem
            href={`https://claude.ai/new?q=${encodeURIComponent(prompt(absolute))}`}
            target="_blank"
            rel="noopener"
          >
            <ExternalLink aria-hidden />
            Open in Claude
          </MenuLinkItem>
        </MenuContent>
      </Menu>
      <span role="status" className="sr-only">
        {state === "copied"
          ? "Page copied as Markdown"
          : state === "failed"
            ? "Couldn't copy the page"
            : ""}
      </span>
    </div>
  )
}
```

## Example: File actions

Shortcuts as keycaps, a submenu, a setting with a lamp, a sort order and a destructive item.

```tsx
"use client"

import * as React from "react"
import { Copy, FolderInput, MoreHorizontal, Pencil, Trash2 } from "lucide-react"
import {
  Menu,
  MenuCheckboxItem,
  MenuContent,
  MenuGroup,
  MenuGroupLabel,
  MenuItem,
  MenuRadioGroup,
  MenuRadioItem,
  MenuSeparator,
  MenuShortcut,
  MenuSub,
  MenuSubContent,
  MenuSubTrigger,
  MenuTrigger,
} from "@/components/ui/menu"

/** A file's ⋯ menu: actions with shortcuts, a submenu, a setting with a lamp, a sort order, and delete. */
export function MenuFileActions() {
  const [preview, setPreview] = React.useState(true)
  const [sort, setSort] = React.useState("modified")
  const [last, setLast] = React.useState<string | null>(null)

  return (
    <div className="flex flex-col items-center gap-4">
      <div className="flex w-72 max-w-full items-center gap-3 rounded-(--bevel-radius-md) border bg-background py-2 pr-2 pl-3">
        <span className="min-w-0 flex-1 truncate text-sm">quarterly-report.pdf</span>
        <Menu>
          <MenuTrigger
            aria-label="Actions for quarterly-report.pdf"
            className="inline-flex size-8 items-center justify-center rounded-(--bevel-radius-sm) text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-muted"
          >
            <MoreHorizontal className="size-4" aria-hidden />
          </MenuTrigger>
          <MenuContent align="end">
            <MenuItem onClick={() => setLast("Rename")} aria-keyshortcuts="F2">
              <Pencil aria-hidden />
              Rename
              <MenuShortcut keys={["F2"]} />
            </MenuItem>
            <MenuItem onClick={() => setLast("Duplicate")} aria-keyshortcuts="Meta+D">
              <Copy aria-hidden />
              Duplicate
              <MenuShortcut keys={["⌘", "D"]} />
            </MenuItem>
            <MenuSub>
              <MenuSubTrigger>
                <FolderInput aria-hidden />
                Move to
              </MenuSubTrigger>
              <MenuSubContent>
                {["Drafts", "Finance", "Archive"].map((folder) => (
                  <MenuItem key={folder} onClick={() => setLast(`Moved to ${folder}`)}>
                    {folder}
                  </MenuItem>
                ))}
              </MenuSubContent>
            </MenuSub>
            <MenuSeparator />
            <MenuCheckboxItem checked={preview} onCheckedChange={setPreview}>
              Show preview
            </MenuCheckboxItem>
            <MenuGroup>
              <MenuGroupLabel>Sort by</MenuGroupLabel>
              <MenuRadioGroup value={sort} onValueChange={setSort}>
                <MenuRadioItem value="name">Name</MenuRadioItem>
                <MenuRadioItem value="modified">Date modified</MenuRadioItem>
                <MenuRadioItem value="size">Size</MenuRadioItem>
              </MenuRadioGroup>
            </MenuGroup>
            <MenuSeparator />
            <MenuItem variant="destructive" onClick={() => setLast("Deleted")} aria-keyshortcuts="Meta+Backspace">
              <Trash2 aria-hidden />
              Delete
              <MenuShortcut keys={["⌘", "⌫"]} />
            </MenuItem>
          </MenuContent>
        </Menu>
      </div>
      <p role="status" className="text-sm text-muted-foreground">
        {last ?? `Preview ${preview ? "on" : "off"}, sorted by ${sort}.`}
      </p>
    </div>
  )
}
```
