Dropdown Menu
An action menu anchored to a button — grouped items, submenus, shortcuts, checkbox and radio rows, and a destructive variant.
- Status
- 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-menuInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/dropdown-menuRun 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
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
└── DropdownMenuItemBasic
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).
Submenu
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).
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | — | |
variant | "default" | "destructive" | — |
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | — |
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | — |
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"andaria-expanded; the popup is arole="menu"ofmenuitem,menuitemcheckboxandmenuitemradiorows 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
DropdownMenuContentre-applies the theme scope on the positioner inside the portal andDropdownMenuPortalre-applies it on adisplay: contentswrapper (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 localcursor-defaultis 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.
| Contract | States tested |
|---|---|
| Behaviour | default, open, closed, highlighted, checked, disabled, submenu-open |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, described, disabled |
| Visual | default, open, closed, highlighted, checked, disabled |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/dropdown-menu.patch. Every hunk:
- OVL-13 — two hosts, because upstream has two.
DropdownMenuContentopens its ownMenu.Portal, so it readsuseInternalThemeScope()and attaches it to the Positioner inside it; the exported pass-throughDropdownMenuPortalwraps its children in adisplay: contentselement carrying the same class, so the escape hatch is scoped too. - OVL-14 —
DropdownMenuContentforwards an optionalcontainerto itsMenu.Portal, andDropdownMenuSubContentinherits it by being one. Upstream forwards none, and a portal defaults todocument.body, which the Fullscreen API paints the fullscreen element over; a fullscreen player's settings menu rendered invisibly behind it. Leavingcontainerunset is upstream's own default, so nothing else moves. - OVL-19 —
DropdownMenuContentisw-max, capped at--available-width, with a minimum of the larger of--spacing(32)and its trigger, in place of upstream'sw-(--anchor-width) min-w-32;DropdownMenuSubContent'sw-autobecomesw-max. A menu is as wide as its longest item, so an item never wraps. - INT-1 —
cursor-defaultis 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 inbase.cssgives every[role^="menuitem"]. - FRM-4 —
data-disabled:pointer-events-noneis dropped from the item, the checkbox item and the radio item, so a disabled row stays hoverable. - BRD-1 —
DropdownMenuContentandDropdownMenuSubContentdraw a real 1pxborder border-borderinstead of upstream'sring-1 ring-foreground/10box-shadow outline. A border survives forced colours and matches Elevation; nothing else about the surface moves. - A11Y-13 — a focused destructive
DropdownMenuItemreadsfocus:text-destructive-texton itsbg-destructive/10wash. 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,DropdownMenuCheckboxItemandDropdownMenuRadioItemeach provide Item'sItemDescriptionContextand pointaria-describedbyat anItemDescriptiononce 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 ownscn. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header; no decision of their own.