← ui

menu

ui

a menu of verbs that teaches each one's shortcut, on a right click or a more button

npx shadcn add @ag/menu
Already installed? Preview an update
npx shadcn add @ag/menu --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-11
  • Fixed: the dropdown's opening motion stands still under reduced motion.
  • New: DropdownMenuLinkItem, a dropdown row that is a link (Base UI's link item): href, or render for a router's own (<Link href />), in the rows' face; it shuts the menu when followed.
Updated 2026-10-11 · release notes

also called dropdown menu, context menu

shelf controls

uses keys, material, portal, shortcut

used by editor, selection, tree-marks, command, tree, table, breadcrumb, components, account-menu, player, film-mosaic, deployment

the demo above, verbatim

menu.demo.tsx
"use client"

import {
  ChevronDown,
  Copy,
  Ellipsis,
  FolderInput,
  Pencil,
  RotateCcw,
  Trash2,
} from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
  Menu,
  type MenuEntry,
} from "@/registry/base-nova/ui/menu"

// #region verbs
// One list of verbs for a file; the hint is the press each stands for.
const verbs = (say: (what: string) => void): MenuEntry[] => [
  {
    icon: <Pencil />,
    label: "rename",
    hint: "enter",
    onSelect: () => say("rename"),
  },
  {
    icon: <Copy />,
    label: "duplicate",
    hint: "mod+d",
    onSelect: () => say("duplicate"),
  },
  { icon: <FolderInput />, label: "move to…", onSelect: () => say("move") },
  "separator",
  {
    icon: <Trash2 />,
    label: "move to trash",
    hint: "mod+backspace",
    destructive: true,
    onSelect: () => say("trash"),
  },
]
// #endregion

function AtThePointer() {
  const [at, setAt] = React.useState<{ x: number; y: number } | null>(null)
  const [said, say] = React.useState("right click the card")
  const card = React.useRef<HTMLButtonElement>(null)
  return (
    <>
      <button
        ref={card}
        type="button"
        onContextMenu={(e) => {
          e.preventDefault()
          setAt({ x: e.clientX, y: e.clientY })
        }}
        className="grid h-32 w-72 place-items-center rounded-lg bg-foreground/4 text-[13px] text-foreground/60"
      >
        {said}
      </button>
      <Menu
        entries={verbs(say)}
        anchor={at}
        onClose={() => setAt(null)}
        returnTo={() => card.current}
      />
    </>
  )
}

function FromAButton() {
  const button = React.useRef<HTMLButtonElement>(null)
  const [open, setOpen] = React.useState(false)
  const [said, say] = React.useState("")
  return (
    <div className="flex items-center gap-3 text-[13px] text-foreground/60">
      <Button
        ref={button}
        variant="ghost"
        size="icon"
        aria-label="more"
        onClick={() => setOpen(true)}
      >
        <Ellipsis />
      </Button>
      {said}
      <Menu
        entries={verbs(say)}
        anchor={open ? button.current : null}
        onClose={() => setOpen(false)}
        returnTo={() => button.current}
      />
    </div>
  )
}

function OnGlass() {
  const button = React.useRef<HTMLButtonElement>(null)
  const [open, setOpen] = React.useState(false)
  const [said, say] = React.useState("")
  return (
    <MaterialProvider material="glass">
      <div className="flex items-center gap-3 rounded-2xl bg-[url(/lightbox/field.jpg)] bg-cover bg-center p-10 text-sm text-white">
        <Button
          ref={button}
          scheme="dark"
          size="icon"
          aria-label="more"
          onClick={() => setOpen(true)}
        >
          <Ellipsis />
        </Button>
        {said}
        <Menu
          entries={verbs(say)}
          anchor={open ? button.current : null}
          onClose={() => setOpen(false)}
          returnTo={() => button.current}
          scheme="dark"
        />
      </div>
    </MaterialProvider>
  )
}

// #region dropdown
// The dropdown a control owns: its trigger, a choice among its rows, a verb.
function Account() {
  const [speed, setSpeed] = React.useState("1")
  const [said, say] = React.useState("")
  return (
    <div className="flex items-center gap-3 text-foreground/60 text-sm">
      <DropdownMenu>
        <DropdownMenuTrigger render={<Button variant="secondary" />}>
          speed {speed}× <ChevronDown />
        </DropdownMenuTrigger>
        <DropdownMenuContent>
          <DropdownMenuLabel>playback speed</DropdownMenuLabel>
          <DropdownMenuRadioGroup value={speed} onValueChange={setSpeed}>
            {["0.5", "1", "1.5", "2"].map((s) => (
              <DropdownMenuRadioItem key={s} value={s}>
                {s}×
              </DropdownMenuRadioItem>
            ))}
          </DropdownMenuRadioGroup>
          <DropdownMenuSeparator />
          <DropdownMenuItem destructive onClick={() => say("reset")}>
            <RotateCcw /> reset everything
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
      {said}
    </div>
  )
}
// #endregion

export default function MenuDemo() {
  return (
    <div className="space-y-8">
      <Sample name="pointer" title="at the pointer" with="verbs">
        <AtThePointer />
      </Sample>
      <Sample
        name="button"
        glance
        title="from the element that asked"
        with="verbs"
      >
        <FromAButton />
      </Sample>
      <Sample
        name="glass"
        title="in glass"
        description="material glass round it: the menu, its keys and the button that opens it"
        with="verbs"
      >
        <OnGlass />
      </Sample>
      <Sample
        name="dropdown"
        title="a dropdown, plain and glass"
        description="the menu's second shape: a control's own trigger and rows composed as parts, a choice among them checked"
        with="dropdown"
      >
        <div className="flex flex-wrap items-center gap-6">
          <Account />
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6 text-white">
              <Account />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

menu.tsx
"use client"

// A menu of verbs for whatever asked: a row's right click, its ⋯, ⇧F10, a selection's
// actions. Every entry carries its glyph and the press it stands for, drawn on the
// right, so the menu teaches the shortcut it replaces. Anchored to the element that
// asked, or to the point a right click was made at (a desktop's context menu). The
// same entries feed `selection-bar`: one list of verbs, shown as a menu or as a bar.
// A key a menu draws is a key it answers to: pressed while it is open, the verb runs
// (`verbFor` lets the set behind it answer the same keys while it is shut). Two
// materials (`material`): plain, the page's popover; glass, the material's glass, its
// rows lit in the text's own colour, its keys glass too. Its second shape, the
// dropdown a control owns (`DropdownMenu` and its parts), is at the foot.

import { Menu as Base } from "@base-ui/react/menu"
import { Check } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  combo,
  type Platform,
  type Press,
  pressed,
} from "@/registry/base-nova/lib/keys"
import {
  type Material,
  MaterialProvider,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { usePortalTarget } from "@/registry/base-nova/ui/portal"
import {
  Shortcut,
  shortcut,
  usePlatform,
} from "@/registry/base-nova/ui/shortcut"

/** A verb, or a line between groups. `hint` is the input it stands in for, written as
 *  `shortcut` reads it ("mod+click", "shift+f10"), drawn as keys and a pointer glyph;
 *  any other text is shown as it is. Several are alternatives, each drawn ("open":
 *  double-click, enter). */
export type MenuEntry =
  | {
      /** what it does, as a glyph: every entry has one, the eye finds the verb by it */
      icon: React.ReactNode
      label: React.ReactNode
      onSelect: () => void
      hint?: string | readonly string[]
      destructive?: boolean
      disabled?: boolean
    }
  | "separator"

/** The verb whose drawn keys are this press, if any: what a menu teaches, it also
 *  answers to, open or not. Pointer hints never match a key. */
export function verbFor(
  entries: readonly MenuEntry[],
  press: Press,
  platform: Platform,
): Exclude<MenuEntry, "separator"> | undefined {
  for (const entry of entries) {
    if (entry === "separator" || entry.disabled || !entry.hint) continue
    const hints = typeof entry.hint === "string" ? [entry.hint] : entry.hint
    if (hints.some((h) => pressed(h, press, platform))) return entry
  }
  return undefined
}

/** A verb's inputs, drawn small: keys and pointer glyphs, or the text as it is.
 *  `keysOnly` keeps the keyboard's alone, for a surface that is pressed and never
 *  double-clicked (a bar), where a pointer glyph would only read as a second icon. */
export function Hints({
  hint,
  keysOnly = false,
  className,
}: {
  hint: string | readonly string[]
  keysOnly?: boolean
  className?: string
}) {
  const all = (typeof hint === "string" ? [hint] : hint).filter(
    (h) => !keysOnly || combo(h) !== null,
  )
  if (all.length === 0) return null
  return (
    <span className={cn("flex items-center gap-2", className)}>
      {all.map((h) =>
        shortcut(h) ? (
          <Shortcut key={h} input={h} size="sm" className="opacity-70" />
        ) : (
          <span key={h} className="text-[11px] text-current/45">
            {h}
          </span>
        ),
      )}
    </span>
  )
}

const ITEM =
  "flex cursor-default select-none items-center gap-4 px-2 py-1.5 text-[13px] text-current/85 outline-none data-[disabled]:opacity-45 data-[highlighted]:text-current"

/** Each material's popup, row and line between groups. Glass: the material's glass
 *  at a menu's blur, its rows lit in the text's own colour, so it reads on any tone;
 *  a destructive row lights in `tone-bad`. */
const FACE = {
  plain: {
    popup:
      "rounded-lg border border-border bg-popover text-popover-foreground shadow-md",
    item: "rounded-md data-[highlighted]:bg-foreground/8",
    bad: "data-[highlighted]:bg-foreground/8",
    line: "bg-foreground/10",
  },
  glass: {
    popup: "[--glass-radius:14px] [--glass-blur:14px]",
    item: "rounded-[10px] data-[highlighted]:bg-current/12",
    bad: "data-[highlighted]:bg-tone-bad/18",
    line: "bg-current/14",
  },
} as const

export function Menu({
  entries,
  anchor,
  onClose,
  returnTo,
  material,
  scheme,
}: {
  entries: readonly MenuEntry[]
  /** the element it opens from, or the point (client px) a right click was made at;
   *  null: closed */
  anchor: Element | { x: number; y: number } | null
  onClose: () => void
  /** where focus goes when it closes */
  returnTo?: () => HTMLElement | null
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
}) {
  const m = useMaterial(material)
  // Over a picture it is read by the part of it under the menu.
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme)
  const face = FACE[m]
  const point = anchor !== null && !(anchor instanceof Element) ? anchor : null
  const at = React.useMemo(
    () =>
      point && {
        getBoundingClientRect: () => new DOMRect(point.x, point.y, 0, 0),
      },
    [point],
  )
  // The entry picked runs once the menu has shut and handed focus back to the row: an
  // entry that opens something focusable (a dialog, a page) takes focus after that,
  // and nothing takes it back. Run on the click, the focus handed back landed after
  // the dialog had taken it, and a dialog that closes on focus out shut as it opened.
  const picked = React.useRef<(() => void) | null>(null)
  const platform = usePlatform()
  const target = usePortalTarget()
  return (
    <Base.Root
      modal={false}
      open={anchor !== null && entries.length > 0}
      onOpenChange={(open) => {
        if (!open) onClose()
      }}
      onOpenChangeComplete={(open) => {
        if (open) return
        const run = picked.current
        picked.current = null
        run?.()
      }}
    >
      <Base.Portal container={target}>
        <Base.Positioner
          anchor={at ?? (anchor as Element | null)}
          side="bottom"
          align={at ? "start" : "end"}
          sideOffset={2}
          className="z-50 outline-none"
        >
          <Base.Popup
            ref={here}
            data-slot="menu"
            finalFocus={returnTo}
            onKeyDown={(event) => {
              // A highlighted entry keeps Enter and Space for itself.
              if (
                (event.key === "Enter" || event.key === " ") &&
                (event.target as HTMLElement).getAttribute("role") ===
                  "menuitem"
              )
                return
              const verb = verbFor(entries, event, platform)
              if (!verb) return
              event.preventDefault()
              event.stopPropagation()
              picked.current = verb.onSelect
              onClose()
            }}
            data-material={m}
            className={cn(
              "min-w-44 p-1 outline-none",
              schemeOf(s),
              m === "glass" && glassVariants({ shape: "surface" }),
              face.popup,
            )}
          >
            <MaterialProvider material={m} scheme={s}>
              {entries.map((entry, i) =>
                entry === "separator" ? (
                  <Base.Separator
                    // biome-ignore lint/suspicious/noArrayIndexKey: entries are rebuilt per open, place is identity
                    key={i}
                    className={cn("mx-1.5 my-1 h-px", face.line)}
                  />
                ) : (
                  <Base.Item
                    // biome-ignore lint/suspicious/noArrayIndexKey: entries are rebuilt per open, place is identity
                    key={i}
                    disabled={entry.disabled}
                    onClick={() => {
                      picked.current = entry.onSelect
                    }}
                    className={cn(
                      ITEM,
                      face.item,
                      entry.destructive && [
                        "text-tone-bad data-[highlighted]:text-tone-bad",
                        face.bad,
                      ],
                    )}
                  >
                    <span
                      aria-hidden="true"
                      className="-mr-2 grid size-4 shrink-0 place-items-center [&_svg]:size-3.5"
                    >
                      {entry.icon}
                    </span>
                    {entry.label}
                    {entry.hint && (
                      <Hints hint={entry.hint} className="ml-auto" />
                    )}
                  </Base.Item>
                ),
              )}
            </MaterialProvider>
          </Base.Popup>
        </Base.Positioner>
      </Base.Portal>
    </Base.Root>
  )
}

// ── THE DROPDOWN, the menu's second shape. A list of verbs (above) is data: whatever
// asked hands its entries over. A dropdown is a control's own: its trigger, and rows
// that may be choices (a speed, a language, an account), composed as parts the way
// shadcn's are:
//
//   <DropdownMenu>
//     <DropdownMenuTrigger render={<Button material="glass">sign in</Button>} />
//     <DropdownMenuContent material="glass">
//       <DropdownMenuItem onClick={…}>github</DropdownMenuItem>
//     </DropdownMenuContent>
//   </DropdownMenu>
//
// A row is a verb (`DropdownMenuItem`), a link (`DropdownMenuLinkItem`) or a choice.
// Base UI's menu underneath (keyboard, typeahead, focus given back, an outside press
// closes it), never modal: a scroll lock rewrites the body's padding and shifts any
// layout that reserves room with it. It opens at least as wide as its trigger, in the
// list's two faces, its rows reading the material from the content.

export function DropdownMenu(props: Omit<Base.Root.Props, "modal">) {
  return <Base.Root modal={false} {...props} />
}

/** The control that opens it; `render` makes any button the trigger. While open it
 *  carries `data-popup-open`. */
export const DropdownMenuTrigger = Base.Trigger

export function DropdownMenuContent({
  material,
  scheme,
  side = "bottom",
  align = "end",
  sideOffset = 6,
  container,
  className,
  children,
  ref,
  ...props
}: Base.Popup.Props &
  Pick<Base.Positioner.Props, "align" | "side" | "sideOffset"> &
  Pick<Base.Portal.Props, "container"> & {
    material?: Material
    /** The colour scheme it stands in (material): default the nearest provider's. */
    scheme?: Scheme
  }) {
  const m = useMaterial(material)
  const target = usePortalTarget()
  // Over a picture (a player's layer, in fullscreen) it is read by the part of it
  // under the menu; its rows wear what it reads.
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
  return (
    <Base.Portal container={container ?? target}>
      <Base.Positioner
        side={side}
        align={align}
        sideOffset={sideOffset}
        className="z-50 outline-none"
      >
        <Base.Popup
          ref={here}
          data-slot="dropdown-menu"
          data-material={m}
          className={cn(
            "min-w-(--anchor-width) p-1 outline-none data-[ending-style]:opacity-0 data-[starting-style]:opacity-0",
            side === "top"
              ? "data-[starting-style]:translate-y-1"
              : "data-[starting-style]:-translate-y-1",
            schemeOf(s),
            m === "glass"
              ? [
                  glassVariants({ shape: "surface" }),
                  "[--glass-moves:opacity_150ms_var(--ease-out),translate_150ms_var(--ease-out)]",
                ]
              : "transition-[opacity,translate] duration-150 ease-out motion-reduce:transition-none",
            FACE[m].popup,
            className,
          )}
          {...props}
        >
          <MaterialProvider material={m} scheme={s}>
            {children}
          </MaterialProvider>
        </Base.Popup>
      </Base.Positioner>
    </Base.Portal>
  )
}

const ROW =
  "flex cursor-default select-none items-center gap-2 px-2 py-1.5 text-[13px] text-current/85 outline-none data-[disabled]:opacity-45 data-[highlighted]:text-current [&_svg]:size-3.5 [&_svg]:shrink-0"

export function DropdownMenuItem({
  destructive,
  className,
  ...props
}: Base.Item.Props & {
  /** A row that destroys: in `tone-bad`, lit red. */
  destructive?: boolean
}) {
  const face = FACE[useMaterial()]
  return (
    <Base.Item
      data-slot="dropdown-menu-item"
      className={cn(
        ROW,
        face.item,
        destructive && [
          "text-tone-bad data-[highlighted]:text-tone-bad",
          face.bad,
        ],
        className,
      )}
      {...props}
    />
  )
}

/** A row that is a link: it goes where its `href` says, as a link does (a new tab on
 *  ⌘ click, its address on hover); `render` takes a router's own (`<Link href />`).
 *  Followed, the menu shuts: a router's navigation keeps the page, and the menu with
 *  it. */
export function DropdownMenuLinkItem({
  className,
  closeOnClick = true,
  ...props
}: Base.LinkItem.Props) {
  const face = FACE[useMaterial()]
  return (
    <Base.LinkItem
      data-slot="dropdown-menu-link-item"
      closeOnClick={closeOnClick}
      className={cn(ROW, face.item, className)}
      {...props}
    />
  )
}

/** One choice among the rows: a radio group, its value the one checked. */
export const DropdownMenuRadioGroup = Base.RadioGroup

/** A row that is a choice: its words, and a check at its end while it is the one. */
export function DropdownMenuRadioItem({
  className,
  children,
  closeOnClick = true,
  ...props
}: Base.RadioItem.Props) {
  const face = FACE[useMaterial()]
  return (
    <Base.RadioItem
      data-slot="dropdown-menu-radio-item"
      closeOnClick={closeOnClick}
      className={cn(ROW, "justify-between gap-3", face.item, className)}
      {...props}
    >
      {children}
      <Base.RadioItemIndicator className="grid place-items-center">
        <Check aria-hidden />
      </Base.RadioItemIndicator>
    </Base.RadioItem>
  )
}

/** A quiet line above the rows (who is signed in), not a row itself. */
export function DropdownMenuLabel({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="dropdown-menu-label"
      className={cn(
        "truncate px-2 py-1.5 text-[11px] text-current/55",
        className,
      )}
      {...props}
    />
  )
}

export function DropdownMenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof Base.Separator>) {
  const face = FACE[useMaterial()]
  return (
    <Base.Separator
      className={cn("mx-1.5 my-1 h-px", face.line, className)}
      {...props}
    />
  )
}