Component · Navigation & Feedback
Dropdown Menu
Overview
A button that opens a small menu of commands — actions the user can run on the current object
or view (rename, duplicate, delete). It is the WAI-ARIA APG
Menu Button pattern: a trigger with
aria-haspopup="menu", and a role="menu" surface whose items are operated with the arrow keys.
Dropdown Menu supports roving keyboard navigation, focus movement into and out of the menu, and typeahead — see Accessibility.
It is not a value picker. Choosing one value from a set that then shows in the trigger is
Select (role="listbox", aria-activedescendant, native <select> expectations).
It is not a navigation list either — a menu of links to other pages is a nav pattern, not this
component. Version 1's items are commands only.
Anatomy
DropdownMenu (state: open/closed, placement prefs)
└─ DropdownMenuTrigger wraps ONE focusable child → aria-haspopup="menu"
└─ DropdownMenuContent role="menu", aria-labelledby the trigger
├─ DropdownMenuLabel non-interactive group heading
├─ DropdownMenuGroup role="group" (aria-labelledby → a Label)
│ ├─ DropdownMenuItem role="menuitem", a native <button>
│ └─ DropdownMenuItem …
├─ DropdownMenuSeparator role="separator", hairline rule
└─ DropdownMenuItem …
DropdownMenu— holds open state (uncontrolleddefaultOpen, or controlledopen+onOpenChange) and theside/alignplacement preference. Renders only arelative inline-flexwrapper so the menu can anchor to the trigger.DropdownMenuTrigger— takes a single focusable child (typically a Button) and clonesaria-haspopup="menu",aria-expanded, andaria-controlsonto it. The child keeps its ownonClick/onKeyDown. The trigger element is whatever you pass — unlike Select, whose trigger has a fixed Input-like appearance.DropdownMenuContent— therole="menu"surface. Owns the arrow-key navigation, typeahead, and the on-open focus move. Labelled by the trigger viaaria-labelledby.DropdownMenuItem— one command. A native<button type="button" role="menuitem">withtabIndex=-1(a single roving focus, moved by the menu, not one Tab stop per item). TakesonSelect— run on click /Enter/Space, after which the menu closes and focus returns to the trigger.DropdownMenuLabel— a non-interactive heading (--muted-foreground, caption size). Give it anidand point aDropdownMenuGroup'saria-labelledbyat it.DropdownMenuSeparator— arole="separator"hairline (--border) between clusters of items.DropdownMenuGroup— arole="group"wrapper for a related cluster.
Live Example
No action chosen yet.
Code Example
"use client";
import * as React from "react";
import { Copy, Pencil, Share2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
/**
* Live Dropdown Menu example — a button that opens a menu of commands
* (WAI-ARIA APG "Menu Button"). `"use client"` so the demo can show which
* command was last chosen; the menu itself manages its own open state, roving
* arrow-key focus (wrapping), typeahead, and `Escape` / outside-click / Tab
* dismissal.
*
* - Opening from the keyboard (`Enter` / `Space` / `ArrowDown`) moves focus to
* the first item; `ArrowUp` opens onto the last.
* - "Move" is disabled: it stays in the arrow-key rotation and is focusable,
* it just does nothing on activation (`aria-disabled`) — the deliberate
* contrast with Select, which skips its disabled options.
* - Every command runs its `onSelect`, then the menu closes and focus returns
* to the trigger.
*/
export function DropdownMenuShowcase() {
const [lastAction, setLastAction] = React.useState<string | null>(null);
return (
<div className="flex flex-col gap-6">
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline">Actions</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel id="dm-file-actions">File</DropdownMenuLabel>
<DropdownMenuGroup aria-labelledby="dm-file-actions">
<DropdownMenuItem onSelect={() => setLastAction("Rename")}>
<Pencil aria-hidden="true" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setLastAction("Duplicate")}>
<Copy aria-hidden="true" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled onSelect={() => setLastAction("Move")}>
<Share2 aria-hidden="true" />
Move…
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setLastAction("Delete")}
className="text-destructive focus:bg-destructive-subtle focus:text-destructive-subtle-foreground"
>
<Trash2 aria-hidden="true" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<p className="text-sm leading-normal text-muted-foreground" aria-live="polite">
{lastAction ? `Last action: ${lastAction}` : "No action chosen yet."}
</p>
</div>
);
}Variants
None. One surface style, one item style. A menu is plainly-styled by definition. The only
expressive room is per-item: pass a leading icon as the item's first child, and use className
for a destructive item (text-destructive plus a --destructive-subtle focus background — see
the live example). No size, tone, or density variant in Version 1.
States
Menu
| State | Look / behaviour |
|---|---|
| Closed | Not rendered (genuinely unmounted, not opacity: 0) |
| Open | --popover surface, --border edge, --shadow-md, --radius-lg, --scale-1 padding; anchored below the trigger, start-aligned; flips above / shifts to end-aligned on viewport collision |
| Opening (keyboard) | Focus moves to the first item (Enter / Space / ArrowDown) or the last item (ArrowUp) |
| Opening (pointer) | Focus moves to the first item |
| Closing | Escape, an outside pointerdown, Tab / Shift+Tab, or the trigger scrolling out of view. Escape and item activation return focus to the trigger; an outside click leaves focus where it landed; Tab lets focus move on naturally |
Item
| State | Look |
|---|---|
| Resting | --popover-foreground text, transparent background, --radius-sm |
| Focused (roving) | --accent background + --accent-foreground text — this is the only "hover" a menu item has; pointer mouseenter moves the roving focus onto the item so keyboard and mouse never disagree |
| Disabled | aria-disabled="true", opacity-50. Still focusable and still in the arrow-key rotation — it simply does nothing when activated. This is deliberate, and the opposite of Select, whose disabled options are skipped the way a native <select> skips a disabled <option> |
| Pressed | No distinct pressed style — activation closes the menu immediately |
Usage Guidance
Tokens
Dropdown Menu uses the same --popover surface family Popover and Select use; it introduces no component-specific styling values.
Implementation note: it reuses Popover's anchoring and dismissal pattern (a relative inline-flex wrapper, a flip/shift collision pass, close on Escape / outside-pointerdown / Tab-out / trigger-scrolled-offscreen) without sharing Popover's contract: this surface is role="menu", never role="dialog".
| Token | Where used | Rationale |
|---|---|---|
--popover / --popover-foreground | Menu surface fill + text | Color names this pair for "menus, tooltips" — Dropdown Menu is the literal case |
--border | Menu edge, DropdownMenuSeparator rule | The system hairline, same as everywhere else |
--shadow-md | Menu elevation | Shadows names "dropdowns, popovers, hover" for this step |
--radius-lg | Menu corner | Radius names "cards, popovers"; the menu shares that surface class |
--radius-sm | Item corner | The smallest step, for a full-width row inside --scale-1 padding |
--accent / --accent-foreground | Focused item background + text | Color names --accent for "hover states, active nav item, selected rows" — a focused menu item is exactly that |
--muted-foreground | DropdownMenuLabel text | Quiet secondary text, matching every other group label in the system |
--scale-1 (p-1) | Menu padding | A tight 4px frame around the item list — Spacing |
--scale-2 / --scale-1-5 (px-2 py-1.5) | Item padding | The same row rhythm Select uses, so the two menus read as siblings |
--scale-4 (size-4) | Leading-icon glyph size | Icons' "inline with text" size, matching Button |
Text recipe: items text-sm leading-snug; labels text-xs font-medium leading-snug.
Do / Don’t
Do
- Use it for commands — verbs that act on the current object or view.
- Give the trigger its own accessible name (an icon-only trigger needs
aria-label). The menu borrows that name viaaria-labelledby. - Order items by frequency or by workflow; group related items with a
DropdownMenuSeparatoror a labelledDropdownMenuGroup. - Put a leading icon on an item only if every sibling in its group has one — a lone icon makes the rest look broken.
- Keep the list short. A menu that needs a scrollbar is usually two menus, or a different pattern.
Don’t
- Don't use it to pick a value that then shows in the trigger — that's Select.
- Don't fill it with links to other pages — that's navigation, not a command menu.
- Don't nest a submenu. Version 1 has no submenu pattern — flatten, or open a Dialog for the deeper choice.
- Don't put a form control (checkbox, text field) inside an item. Checkbox/radio menu items are deferred; a form belongs in a Popover or Dialog.
- Don't trap focus. A Dropdown Menu is non-modal — the page behind it stays operable, and
Tableaves the menu (closing it).
Accessibility
-
Roles: trigger carries
aria-haspopup="menu"+aria-expanded+aria-controls; the surface isrole="menu"witharia-orientation="vertical", labelled by the trigger viaaria-labelledby; each command isrole="menuitem"on a native<button>. -
Keyboard:
Key From the trigger Inside the menu Enter/SpaceOpen, focus first item Activate the focused item, then close + restore focus ArrowDownOpen, focus first item Move focus to the next item — wraps from last to first ArrowUpOpen, focus last item Move focus to the previous item — wraps from first to last Home/End— Focus the first / last item Printable character — Typeahead: focus the next item whose label starts with the typed string (buffer clears after ~500 ms) Escape— Close and return focus to the trigger Tab/Shift+Tab— Close the menu; focus moves on to the next / previous page element -
Disabled items stay in the arrow-key rotation and remain focusable (they are marked
aria-disabled="true", not given the nativedisabledattribute) so a screen-reader user hears that the command exists but is currently unavailable. Activation is a no-op. -
Focus: a single roving
tabIndex=-1across items — the menu is one Tab stop, and the arrow keys move focus within it. On open, focus moves into the menu; on close byEscapeor activation, it returns to the trigger. -
Non-modal: the page behind the menu is never marked
inertand focus is never trapped — that is Dialog's contract, not this one. -
Contrast:
--accent-foregroundon--accentis the same focused-row pairing the rest of the system uses; resting--popover-foregroundon--popoveris near-maximal contrast in both themes.
Related Components / Patterns
- Select — choosing a value rather than running a command.
- Popover — the anchoring and dismissal pattern Dropdown Menu reuses.
- Combobox / Command — a filterable list of commands.