Skip to content
Monogem

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 (uncontrolled defaultOpen, or controlled open + onOpenChange) and the side / align placement preference. Renders only a relative inline-flex wrapper so the menu can anchor to the trigger.
  • DropdownMenuTrigger — takes a single focusable child (typically a Button) and clones aria-haspopup="menu", aria-expanded, and aria-controls onto it. The child keeps its own onClick / onKeyDown. The trigger element is whatever you pass — unlike Select, whose trigger has a fixed Input-like appearance.
  • DropdownMenuContent — the role="menu" surface. Owns the arrow-key navigation, typeahead, and the on-open focus move. Labelled by the trigger via aria-labelledby.
  • DropdownMenuItem — one command. A native <button type="button" role="menuitem"> with tabIndex=-1 (a single roving focus, moved by the menu, not one Tab stop per item). Takes onSelect — 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 an id and point a DropdownMenuGroup's aria-labelledby at it.
  • DropdownMenuSeparator — a role="separator" hairline (--border) between clusters of items.
  • DropdownMenuGroup — a role="group" wrapper for a related cluster.

Live Example

No action chosen yet.

Code Example

dropdown-menu-showcase.tsx
"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

StateLook / behaviour
ClosedNot 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
ClosingEscape, 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

StateLook
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
Disabledaria-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>
PressedNo 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".

TokenWhere usedRationale
--popover / --popover-foregroundMenu surface fill + textColor names this pair for "menus, tooltips" — Dropdown Menu is the literal case
--borderMenu edge, DropdownMenuSeparator ruleThe system hairline, same as everywhere else
--shadow-mdMenu elevationShadows names "dropdowns, popovers, hover" for this step
--radius-lgMenu cornerRadius names "cards, popovers"; the menu shares that surface class
--radius-smItem cornerThe smallest step, for a full-width row inside --scale-1 padding
--accent / --accent-foregroundFocused item background + textColor names --accent for "hover states, active nav item, selected rows" — a focused menu item is exactly that
--muted-foregroundDropdownMenuLabel textQuiet secondary text, matching every other group label in the system
--scale-1 (p-1)Menu paddingA tight 4px frame around the item list — Spacing
--scale-2 / --scale-1-5 (px-2 py-1.5)Item paddingThe same row rhythm Select uses, so the two menus read as siblings
--scale-4 (size-4)Leading-icon glyph sizeIcons' "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 via aria-labelledby.
  • Order items by frequency or by workflow; group related items with a DropdownMenuSeparator or a labelled DropdownMenuGroup.
  • 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 Tab leaves the menu (closing it).

Accessibility

  • Roles: trigger carries aria-haspopup="menu" + aria-expanded + aria-controls; the surface is role="menu" with aria-orientation="vertical", labelled by the trigger via aria-labelledby; each command is role="menuitem" on a native <button>.

  • Keyboard:

    KeyFrom the triggerInside the menu
    Enter / SpaceOpen, focus first itemActivate the focused item, then close + restore focus
    ArrowDownOpen, focus first itemMove focus to the next item — wraps from last to first
    ArrowUpOpen, focus last itemMove 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 native disabled attribute) so a screen-reader user hears that the command exists but is currently unavailable. Activation is a no-op.

  • Focus: a single roving tabIndex=-1 across items — the menu is one Tab stop, and the arrow keys move focus within it. On open, focus moves into the menu; on close by Escape or activation, it returns to the trigger.

  • Non-modal: the page behind the menu is never marked inert and focus is never trapped — that is Dialog's contract, not this one.

  • Contrast: --accent-foreground on --accent is the same focused-row pairing the rest of the system uses; resting --popover-foreground on --popover is 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.