Overview
The Dropdown Menu (also called a context menu, action menu, or popover menu) is an overlay component that displays a list of actions or options when triggered by a button, icon, or right-click. It's the standard mechanism for offering contextual actions without cluttering the primary UI.
Dropdown menus are deceptively complex. On the surface they're "just a list of buttons in a floating panel," but the interaction design involves precise focus management, submenus with hover intent detection, keyboard navigation across nested levels, scroll handling, viewport collision detection, and careful ARIA semantics that differ from other overlay patterns.
When to use a Dropdown Menu:
- To present a set of actions related to an element (edit, delete, duplicate, share)
- For "more actions" overflow menus in toolbars, table rows, or Card headers
- For application-level menus (File, Edit, View) in desktop-style interfaces
- When a Split Button needs a secondary action set
When NOT to use a Dropdown Menu:
- For selecting a form value — use a Select or Combobox instead
- For navigation links — use a Navigation Bar with submenus
- For rich, non-action content — use a Popover
- For a single toggle action — use a Switch or Checkbox
- For displaying informational text — use a Tooltip
The critical distinction: Dropdown Menus are for actions (verbs — "Delete", "Rename", "Export"). Selects are for values (nouns — choosing a country, a status, a color). Mixing these patterns confuses users and breaks accessibility expectations.
Style your menu's elevation with the Shadow Generator, fine-tune open/close motion with the Transition Generator, and verify text contrast using the Contrast Checker.
Variants
Trigger Variants
| Trigger | Description | Use Case |
|---|---|---|
| Button trigger | A visible button (often with a chevron icon ▾) opens the menu | Primary pattern — toolbars, page headers |
| Icon-only trigger | A compact icon button (⋯ or ⋮) opens the menu | Table rows, cards, tight layouts. Always add aria-label. |
| Right-click / Context menu | Menu appears at the cursor position on right-click | File managers, canvas apps, IDE-style interfaces |
| Composite trigger | Part of a Split Button — default action + menu | When there's a clear primary action with alternatives |
Menu Content Variants
| Variant | Description |
|---|---|
| Simple | Flat list of menu items, each triggering an action |
| Grouped | Items separated by dividers into logical groups (e.g., "Edit" group, "Danger" group) |
| With icons | Each item has a leading icon for visual scanning |
| With shortcuts | Keyboard shortcut labels right-aligned (e.g., ⌘C, Ctrl+V) |
| With submenus | Nested menus that open on hover/arrow-key, indicated by a right-pointing chevron |
| With checkmarks | Items that toggle state, showing a checkmark when active (like view options) |
| With radio items | Mutually exclusive options within a group (e.g., sort direction) |
| Destructive items | Red-styled items for dangerous actions (delete, revoke). Always place last in a group, separated by a divider. |
Size Variants
| Size | Min Width | Item Height | Font Size | Use Case |
|---|---|---|---|---|
| Small | 160px | 32px | 13px | Dense UIs, nested submenus |
| Medium | 200px | 36px | 14px | Default for most interfaces |
| Large | 240px | 44px | 15px | Touch devices, spacious layouts |
Properties
Dropdown Menu Properties
| Property | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Controlled open state |
onOpenChange | (open: boolean) => void | — | Callback when menu opens or closes |
trigger | ReactNode | — | The element that opens the menu |
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Preferred side for menu placement |
align | 'start' | 'center' | 'end' | 'start' | Alignment relative to the trigger |
sideOffset | number | 4 | Pixel gap between trigger and menu |
modal | boolean | true | Whether the menu behaves modally (traps pointer events outside) |
loop | boolean | false | Whether keyboard navigation loops from last item to first |
Menu Item Properties
| Property | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Prevents interaction and greys out the item |
onSelect | () => void | — | Callback when the item is selected |
destructive | boolean | false | Applies destructive (red) styling |
shortcut | string | — | Keyboard shortcut text displayed right-aligned |
icon | ReactNode | — | Leading icon |
Sub-Menu Properties
| Property | Type | Default | Description |
|---|---|---|---|
label | string | — | Submenu trigger label |
disabled | boolean | false | Prevents submenu from opening |
Token Mappings
Design Token Mappings
| Token Category | Token Example | Menu Usage |
|---|---|---|
| Color – Surface | --color-surface-elevated | Menu panel background |
| Color – Item Hover | --color-surface-hover | Hovered menu item background |
| Color – Item Active | --color-primary-100 | Focused/active item background |
| Color – Text | --color-text-primary | Menu item label |
| Color – Text Muted | --color-text-tertiary | Shortcut labels, descriptions |
| Color – Destructive | --color-error-600 | Destructive item text and icon |
| Color – Divider | --color-border-subtle | Group separators |
| Border Radius | --radius-lg (12px) | Menu panel corners |
| Border Radius – Item | --radius-sm (4px) | Individual item hover radius |
| Shadow | --shadow-lg | Menu panel elevation. Configure with Shadow Generator. |
| Spacing | --space-1 (4px) | Padding between panel edge and items |
| Spacing – Item | --space-2 (8px), --space-3 (12px) | Item horizontal padding |
| Typography | --font-size-sm, --font-weight-normal | Item text |
| Transition | --duration-fast (100ms), --ease-out | Open/close + hover transitions. Preview with Transition Generator. |
| Z-index | --z-dropdown (40) | Menu stacking order |
For a deep dive on token naming, see our Design Tokens Complete Guide.
States
Menu States
| State | Behavior |
|---|---|
| Closed | Menu is not rendered (or hidden). Trigger is in default state. |
| Opening | Menu fades in and optionally scales from origin point. Use Animation Generator for spring curves. Duration: 100–150ms. |
| Open | Menu visible. First item optionally receives focus. Clicking outside or pressing Escape closes. |
| Closing | Reverse animation, 75–100ms. Faster than opening for responsive feel. |
Menu Item States
| State | Visual Treatment |
|---|---|
| Default | Normal text color, transparent background |
| Hover / Focused | Background highlight (--color-surface-hover). Rounded corners on the highlight. |
| Active / Pressed | Slightly darker background. Brief flash before the menu closes. |
| Disabled | Reduced opacity (0.5). cursor: not-allowed. Item is skipped in keyboard navigation. |
| Destructive | Text and icon in --color-error-600. Hover background uses --color-error-50. |
| Checked | Checkmark icon visible. For toggle items (role="menuitemcheckbox"). |
Submenu States
| State | Behavior |
|---|---|
| Trigger hover | After a brief delay (50–100ms), the submenu opens. Immediate open on arrow-right key. |
| Hover intent | A "safe triangle" between the trigger and submenu prevents accidental closure when the user moves their cursor diagonally to the submenu. This is essential — without it, submenus are infuriating. |
| Submenu open | Parent menu item stays highlighted. Submenu appears to the right (or left if insufficient space). |
| Submenu closing | Brief delay (150ms) before closing when focus leaves — prevents flicker on diagonal mouse movement. |
Accessibility
WCAG Requirements
| Criterion | Level | Requirement |
|---|---|---|
| SC 1.4.3 Contrast (Minimum) | AA | Menu item text must have 4.5:1 contrast against the menu background. Shortcut text and descriptions need 4.5:1 against the surface. Verify all combinations with the Contrast Checker. |
| SC 1.4.11 Non-text Contrast | AA | Focus indicator, icons, and dividers need 3:1 contrast against adjacent colors. |
| SC 2.1.1 Keyboard | A | All menu items and submenus must be fully operable via keyboard. |
| SC 2.4.3 Focus Order | A | Focus must move logically through menu items and into/out of submenus. |
| SC 2.4.7 Focus Visible | AA | Active/focused menu item must have a visible indicator. |
| SC 4.1.2 Name, Role, Value | A | Menu must use correct role attributes. Trigger must indicate it opens a menu. |
ARIA Roles & Attributes
The ARIA menu pattern is specific and strict. Do not use role="menu" for navigation — it's for action menus only.
- Trigger:
aria-haspopup="menu",aria-expanded="true|false",aria-controls="menu-id" - Menu container:
role="menu",idmatching the trigger'saria-controls - Menu items:
role="menuitem" - Checkbox items:
role="menuitemcheckbox",aria-checked="true|false" - Radio items:
role="menuitemradio",aria-checked="true|false"within arole="group"witharia-label - Submenu trigger:
role="menuitem",aria-haspopup="menu",aria-expanded="true|false" - Separators:
role="separator" - Disabled items:
aria-disabled="true"(not thedisabledattribute — keep them discoverable)
Keyboard Interaction
| Key | Action |
|---|---|
| Enter / Space | Opens menu from trigger; activates focused menu item |
| Arrow Down | Opens menu from trigger (if closed); moves focus to next item |
| Arrow Up | Moves focus to previous item. From first item, moves to last (if loop). |
| Arrow Right | Opens submenu (when focus is on a submenu trigger); moves into it |
| Arrow Left | Closes current submenu; returns focus to parent menu trigger |
| Home | Moves focus to first menu item |
| End | Moves focus to last menu item |
| Escape | Closes the menu; returns focus to the trigger |
| A–Z (type-ahead) | Moves focus to the first item starting with that letter |
Screen Reader Behavior
When the trigger is activated, screen readers announce: "Menu, [label], [n] items." As the user navigates with arrows, each item is announced with its role ("menu item", "menu item checkbox, checked"), label, and position ("2 of 6"). This is why correct role attributes are non-negotiable.
For more on menu accessibility patterns, see our ARIA Attributes Guide and Keyboard Accessibility Guide.
Usage Guidelines
Do's
- ✅ Group related actions with dividers. A menu with 8 ungrouped items is hard to scan — group them (Edit group, Share group, Danger group).
- ✅ Use icons consistently. Either all items have icons or none do. Mixing creates visual misalignment.
- ✅ Place destructive actions last, separated by a divider. Users scan top-to-bottom; dangerous actions at the end prevent accidental triggers.
- ✅ Show keyboard shortcuts when they exist. This trains power users and adds perceived quality.
- ✅ Keep menus under 10 items. Beyond that, consider grouping, submenus, or a Command Palette pattern.
- ✅ Use verb-first labels for actions: "Edit profile", "Copy link", "Delete workspace".
Don'ts
- ❌ Don't use dropdown menus for navigation. Menus are for actions (
role="menu"), not links. For navigation dropdowns, use a nav with<a>tags and appropriate ARIA. - ❌ Don't nest more than one level of submenus. Two levels deep is already pushing it — three levels is a UX failure. Flatten or redesign.
- ❌ Don't auto-close the menu for checkbox/radio items. When users are toggling settings, the menu should stay open until they explicitly close it.
- ❌ Don't use a dropdown menu with fewer than 2 items. That's just a button with extra steps.
- ❌ Don't forget the safe-triangle for submenus. Without hover-intent detection, diagonal mouse movement closes the submenu before users can reach it.
Menu Item Content Guidelines
- Labels: 1–3 words, verb-first ("Rename", "Move to folder", "Export as PDF")
- Descriptions (optional): One line below the label for context. Keep under 50 characters.
- Shortcuts: Use platform-appropriate modifiers (⌘ on Mac, Ctrl on Windows). Display with
<kbd>styling. - Icons: 16–20px, monochrome, matching the text color. Use
aria-hidden="true"on decorative icons.
Code Snippets
<!-- Dropdown Menu Trigger -->
<div class="dropdown-menu-root">
<button
type="button"
class="btn btn-secondary"
aria-haspopup="menu"
aria-expanded="false"
aria-controls="actions-menu"
id="actions-trigger"
>
Actions
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.47 5.97a.75.75 0 0 1 1.06 0L8 8.44l2.47-2.47a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 0 1 0-1.06Z"/>
</svg>
</button>
<!-- Menu Panel -->
<div
role="menu"
id="actions-menu"
aria-labelledby="actions-trigger"
class="dropdown-panel"
hidden
>
<button role="menuitem" class="menu-item" tabindex="-1">
<svg aria-hidden="true" width="16" height="16" fill="currentColor"><use href="#icon-edit"/></svg>
Edit
<kbd class="shortcut">⌘E</kbd>
</button>
<button role="menuitem" class="menu-item" tabindex="-1">
<svg aria-hidden="true" width="16" height="16" fill="currentColor"><use href="#icon-copy"/></svg>
Duplicate
<kbd class="shortcut">⌘D</kbd>
</button>
<div role="separator" class="menu-divider"></div>
<button role="menuitem" class="menu-item menu-item-destructive" tabindex="-1">
<svg aria-hidden="true" width="16" height="16" fill="currentColor"><use href="#icon-trash"/></svg>
Delete
<kbd class="shortcut">⌫</kbd>
</button>
</div>
</div>import { useState, useRef, useEffect, type ReactNode } from "react";
interface MenuItem {
label: string;
icon?: ReactNode;
shortcut?: string;
destructive?: boolean;
disabled?: boolean;
onSelect: () => void;
}
interface MenuGroup {
items: MenuItem[];
}
interface DropdownMenuProps {
trigger: ReactNode;
groups: MenuGroup[];
side?: "top" | "bottom";
align?: "start" | "end";
}
export default function DropdownMenu({
trigger,
groups,
side = "bottom",
align = "start",
}: DropdownMenuProps) {
const [open, setOpen] = useState(false);
const [focusIndex, setFocusIndex] = useState(-1);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const allItems = groups.flatMap((g) => g.items);
const enabledIndices = allItems
.map((item, i) => (!item.disabled ? i : -1))
.filter((i) => i !== -1);
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
const currentPos = enabledIndices.indexOf(focusIndex);
const next = enabledIndices[(currentPos + 1) % enabledIndices.length];
setFocusIndex(next);
break;
}
case "ArrowUp": {
e.preventDefault();
const currentPos = enabledIndices.indexOf(focusIndex);
const prev = enabledIndices[(currentPos - 1 + enabledIndices.length) % enabledIndices.length];
setFocusIndex(prev);
break;
}
case "Escape":
setOpen(false);
triggerRef.current?.focus();
break;
case "Enter":
case " ":
e.preventDefault();
if (focusIndex >= 0 && !allItems[focusIndex].disabled) {
allItems[focusIndex].onSelect();
setOpen(false);
triggerRef.current?.focus();
}
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, focusIndex]);
let itemIndex = 0;
return (
<div className="dropdown-root" style={{ position: "relative" }}>
<button
ref={triggerRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => { setOpen(!open); setFocusIndex(enabledIndices[0] ?? -1); }}
>
{trigger}
</button>
{open && (
<div ref={menuRef} role="menu" className="dropdown-panel">
{groups.map((group, gi) => (
<div key={gi} role="group">
{gi > 0 && <div role="separator" className="menu-divider" />}
{group.items.map((item) => {
const idx = itemIndex++;
return (
<button
key={idx}
role="menuitem"
className={`menu-item ${item.destructive ? "destructive" : ""}`}
tabIndex={focusIndex === idx ? 0 : -1}
aria-disabled={item.disabled || undefined}
ref={(el) => { if (focusIndex === idx) el?.focus(); }}
onClick={() => {
if (!item.disabled) { item.onSelect(); setOpen(false); }
}}
>
{item.icon}
<span>{item.label}</span>
{item.shortcut && <kbd className="shortcut">{item.shortcut}</kbd>}
</button>
);
})}
</div>
))}
</div>
)}
</div>
);
}Design Systems
Cross-System Comparison
| Feature | Material 3 | Shadcn/ui | Radix | Ant Design |
|---|---|---|---|---|
| Component | Menu (Exposed Dropdown Menu) | DropdownMenu (wraps Radix) | DropdownMenu primitive | Dropdown (with Menu) |
| Submenus | Not built-in | Full support via Radix | DropdownMenu.Sub | Menu with nested SubMenu |
| Checkbox items | Not built-in | DropdownMenuCheckboxItem | CheckboxItem primitive | Not built-in |
| Radio items | Not built-in | DropdownMenuRadioGroup | RadioGroup + RadioItem | Not built-in |
| Keyboard navigation | Full arrow-key support | Full (Radix) | Gold standard — type-ahead, Home/End, loops | Arrow keys, Enter |
| Animation | Material motion curves | Tailwind CSS transitions | BYO animation (CSS or Framer) | Ant Motion (slide + fade) |
| Positioning | Manual or anchor-based | Radix's Popper (collision-aware) | Popper engine — flip, shift, resize | Trigger-aligned dropdown |
| Context menu | Not distinct | Separate ContextMenu component | ContextMenu primitive | Via trigger="contextMenu" |
Notable Approaches
Radix DropdownMenu is the gold standard for accessible menu primitives. It handles the full WAI-ARIA Menu pattern: role="menu", role="menuitem", role="menuitemcheckbox", role="menuitemradio", focus management with roving tabindex, type-ahead search, submenu hover-intent (the safe-triangle algorithm), and collision-aware positioning. Shadcn/ui wraps Radix directly, adding only styling.
Material 3 takes a simpler approach. Their "Menu" is a positioned surface with list items — closer to a popover than a full menu primitive. It lacks checkbox/radio item semantics and submenus out of the box, reflecting Material's philosophy that complex menus should be replaced with other patterns (bottom sheets, dialogs).
Ant Design's Dropdown is a composition of the Dropdown positioning wrapper and the Menu component (which also powers the sidebar navigation). This means the same Menu API handles both navigation menus and action menus — convenient, but the semantic distinction between role="menu" (actions) and role="navigation" gets blurred.
Hover-intent and the safe triangle: The "safe triangle" algorithm (also called "aim-aware submenus") solves a specific UX problem: when a user hovers a submenu trigger and moves their cursor diagonally toward the submenu, the cursor briefly passes over other menu items. Without the safe triangle, this closes the submenu and opens the wrong one. Amazon famously solved this in their mega-menu navigation. Radix implements it natively; if you build your own menu, this detail is essential.
For animation values that feel natural, try our Animation Generator with ease-out curves at 100–150ms.