Skip to content
Component installs need the registry setup
VegaStack Design

Dropdown Menu

An action menu anchored to a button — grouped items, submenus, shortcuts, checkbox and radio rows, and a destructive variant.

Status
stable
Since
0.1.0
Accessibility pattern
APG menu button

Last updated

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Dropdown Menu before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify dropdown-menu

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/dropdown-menu

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/item.

Usage

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

<DropdownMenu>
  <DropdownMenuTrigger render={<Button variant="outline" />}>
    Open
  </DropdownMenuTrigger>
  <DropdownMenuContent>
    <DropdownMenuGroup>
      <DropdownMenuLabel>My Account</DropdownMenuLabel>
      <DropdownMenuItem>Profile</DropdownMenuItem>
      <DropdownMenuItem>Billing</DropdownMenuItem>
    </DropdownMenuGroup>
    <DropdownMenuSeparator />
    <DropdownMenuGroup>
      <DropdownMenuItem>Team</DropdownMenuItem>
      <DropdownMenuItem>Subscription</DropdownMenuItem>
    </DropdownMenuGroup>
  </DropdownMenuContent>
</DropdownMenu>;

Anatomy

DropdownMenu — data-slot="dropdown-menu"
DropdownMenuPortal — data-slot="dropdown-menu-portal"
DropdownMenuTrigger — data-slot="dropdown-menu-trigger"
DropdownMenuContent — data-slot="dropdown-menu-content"
DropdownMenuGroup — data-slot="dropdown-menu-group"
DropdownMenuLabel — data-slot="dropdown-menu-label"
DropdownMenuItem — data-slot="dropdown-menu-item"
DropdownMenuCheckboxItem — data-slot="dropdown-menu-checkbox-item" | "dropdown-menu-checkbox-item-indicator"
DropdownMenuRadioGroup — data-slot="dropdown-menu-radio-group"
DropdownMenuRadioItem — data-slot="dropdown-menu-radio-item" | "dropdown-menu-radio-item-indicator"
DropdownMenuSeparator — data-slot="dropdown-menu-separator"
DropdownMenuShortcut — data-slot="dropdown-menu-shortcut"
DropdownMenuSub — data-slot="dropdown-menu-sub"
DropdownMenuSubTrigger — data-slot="dropdown-menu-sub-trigger"
DropdownMenuSubContent — data-slot="dropdown-menu-sub-content"

Examples

Composition

DropdownMenu
├── DropdownMenuTrigger
└── DropdownMenuContent
    ├── DropdownMenuGroup
    │   ├── DropdownMenuLabel
    │   ├── DropdownMenuItem
    │   └── DropdownMenuItem
    ├── DropdownMenuSeparator
    ├── DropdownMenuGroup
    │   ├── DropdownMenuLabel
    │   └── DropdownMenuCheckboxItem
    ├── DropdownMenuSeparator
    ├── DropdownMenuGroup
    │   ├── DropdownMenuLabel
    │   └── DropdownMenuRadioGroup
    │       ├── DropdownMenuRadioItem
    │       └── DropdownMenuRadioItem
    └── DropdownMenuSub
        ├── DropdownMenuSubTrigger
        └── DropdownMenuSubContent
            └── DropdownMenuGroup
                ├── DropdownMenuLabel
                ├── DropdownMenuItem
                └── DropdownMenuItem

Basic

Labels name a group, separators divide them, and disabled on an item keeps the row in the menu. A disabled item keeps focus and shows its reason as a description; Tooltip is not the reason carrier (see Description line).

DropdownMenuSub wraps a DropdownMenuSubTrigger and a DropdownMenuSubContent; the submenu opens on hover, on → and on Enter, and nests as deep as the data does. Wrap the sub content in DropdownMenuPortal when the parent popup would otherwise clip it.

Shortcuts

DropdownMenuShortcut is a <span> pushed to the inline end with ms-auto. It is a hint, not a binding — the key handler is yours.

Icons

A leading lucide-react icon is sized by the item itself ([&_svg:not([class*='size-'])]:size-4), so pass a className rather than a size prop.

Checkboxes

DropdownMenuCheckboxItem renders role="menuitemcheckbox" with aria-checked, and reserves the inline-end indicator column whether or not it is checked.

Checkboxes Icons

A checkbox row takes a leading icon like any other item; the check indicator stays in its own column at the inline end, so the two never compete.

Radio Group

DropdownMenuRadioGroup owns the value; each DropdownMenuRadioItem is a role="menuitemradio", and selecting one clears the rest.

Radio Icons

The same exclusive group with leading icons — useful when the options are recognised by their mark before their label.

Destructive

variant="destructive" sets data-variant="destructive" on the row, which tints the label, the icon and the focus wash from the destructive token; the focused label reads in the destructive-text ink so it clears AA on the wash. Reserve it for the irreversible action, and never for more than one row in a group.

Avatar

The trigger composes through Base UI's render prop, so an icon Button holding an Avatar is the trigger rather than a wrapper around one. Give that button an aria-label — an avatar image is not an accessible name.

Complex

Groups, labels, shortcuts, checkbox and radio rows, and three levels of submenu in one menu — the upper bound of what belongs in a dropdown before it should become a dialog or a page.

RTL

The item padding, the shortcut's ms-auto, the submenu chevron's rtl:rotate-180 and the popup's alignment are all logical, so a DirectionProvider set to rtl mirrors the whole menu without a second stylesheet.

Description line

A row that needs a second line composes Item's parts: ItemContent › ItemTitle + ItemDescription. DropdownMenuItem, DropdownMenuCheckboxItem and DropdownMenuRadioItem each provide Item's description context, so the description's id lands on the row's aria-describedby and a screen reader hears it as the row's description (API-19). This is also where a disabled row says why: a disabled item keeps focus and shows its reason as a description; Tooltip is not the reason carrier.

API Reference

DropdownMenuContent adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

DropdownMenuSubContent adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).

PropTypeDefaultDescription
insetboolean—
variant"default" | "destructive"—
PropTypeDefaultDescription
insetboolean—
PropTypeDefaultDescription
insetboolean—

DropdownMenu, DropdownMenuTrigger, DropdownMenuGroup, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub and DropdownMenuPortal add no props of their own — each accepts everything the Base UI part accepts, and DropdownMenuPortal is a pass-through that re-applies the theme scope.

Accessibility

  • Renders Base UI's Menu: the trigger is a button with aria-haspopup="menu" and aria-expanded; the popup is a role="menu" of menuitem, menuitemcheckbox and menuitemradio rows with type-ahead, ↑/↓, Home/End, →/← for submenus and Esc to close.
  • Closing returns focus to the trigger, and a submenu closing returns it to its sub trigger.
  • The popup portals out of the trigger's subtree, so DropdownMenuContent re-applies the theme scope on the positioner inside the portal and DropdownMenuPortal re-applies it on a display: contents wrapper (OVL-13) — a dark or scoped surface survives the portal.
  • Rows carry the hand cursor from the one global rule in base.css (INT-1); upstream's local cursor-default is removed.
  • A disabled item keeps focus and shows its reason as a description; Tooltip is not the reason carrier. ↑/↓ land on it and Enter does nothing — Base UI keeps every row focusable when disabled — and its pointer events stay alive too (FRM-4).
  • Give an icon-only trigger an aria-label.
ContractStates tested
Behaviourdefault, open, closed, highlighted, checked, disabled, submenu-open
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, described, disabled
Visualdefault, open, closed, highlighted, checked, disabled

Do / Don't

Do
Use a dropdown menu for actions on the thing the trigger names — commands, navigation, and the occasional exclusive setting.
Don't
Use it as a form control for choosing a value; that is a Select, which reports a value and participates in a form.

Deviations

Upstream's file plus packages/ui/upstream/patches/dropdown-menu.patch. Every hunk:

  • OVL-13 — two hosts, because upstream has two. DropdownMenuContent opens its own Menu.Portal, so it reads useInternalThemeScope() and attaches it to the Positioner inside it; the exported pass-through DropdownMenuPortal wraps its children in a display: contents element carrying the same class, so the escape hatch is scoped too.
  • OVL-14 — DropdownMenuContent forwards an optional container to its Menu.Portal, and DropdownMenuSubContent inherits it by being one. Upstream forwards none, and a portal defaults to document.body, which the Fullscreen API paints the fullscreen element over; a fullscreen player's settings menu rendered invisibly behind it. Leaving container unset is upstream's own default, so nothing else moves.
  • OVL-19 — DropdownMenuContent is w-max, capped at --available-width, with a minimum of the larger of --spacing(32) and its trigger, in place of upstream's w-(--anchor-width) min-w-32; DropdownMenuSubContent's w-auto becomes w-max. A menu is as wide as its longest item, so an item never wraps.
  • INT-1 — cursor-default is removed from the item, the submenu trigger, the checkbox item and the radio item, so a menu row shows the hand cursor the one global rule in base.css gives every [role^="menuitem"].
  • FRM-4 — data-disabled:pointer-events-none is dropped from the item, the checkbox item and the radio item, so a disabled row stays hoverable.
  • BRD-1 — DropdownMenuContent and DropdownMenuSubContent draw a real 1px border border-border instead of upstream's ring-1 ring-foreground/10 box-shadow outline. A border survives forced colours and matches Elevation; nothing else about the surface moves.
  • A11Y-13 — a focused destructive DropdownMenuItem reads focus:text-destructive-text on its bg-destructive/10 wash. Upstream inks it with the fill (focus:text-destructive), which measures 3.99:1 on its own tint in light, under the AA floor. The resting ink measures 4.77:1 in light and 6.21:1 in dark on the popup, over the floor, so it and the icon ink stay upstream's.
  • API-19 — DropdownMenuItem, DropdownMenuCheckboxItem and DropdownMenuRadioItem each provide Item's ItemDescriptionContext and point aria-describedby at an ItemDescription once one registers inside the row, so a two-line row reads its second line as the row's accessible description (Description line). A one-line row is unchanged.
  • DOC-2 — import { cn } from "cn" becomes @vegastack/design; our npm layer owns cn.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance header; no decision of their own.

On this page