Loading…
Loading…
A menu that appears on trigger, displaying a list of actions or options.
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:
When NOT to use a Dropdown Menu:
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.
| 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 |
| 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 | 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 |
| 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 |
| 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 |
| Property | Type | Default | Description |
|---|---|---|---|
label | string | — | Submenu trigger label |
disabled | boolean | false | Prevents submenu from opening |
| 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.
| 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. |
| 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"). |
| 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. |
| 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. |
The ARIA menu pattern is specific and strict. Do not use role="menu" for navigation — it's for action menus only.
aria-haspopup="menu", aria-expanded="true|false", aria-controls="menu-id"role="menu", id matching the trigger's aria-controlsrole="menuitem"role="menuitemcheckbox", aria-checked="true|false"role="menuitemradio", aria-checked="true|false" within a role="group" with aria-labelrole="menuitem", aria-haspopup="menu", aria-expanded="true|false"role="separator"aria-disabled="true" (not the disabled attribute — keep them discoverable)| 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 |
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.
role="menu"), not links. For navigation dropdowns, use a nav with <a> tags and appropriate ARIA.<kbd> styling.aria-hidden="true" on decorative icons.<!-- 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>
);
}| 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" |
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.