Skip to content

Dropdown Menu

A menu that appears on trigger, displaying a list of actions or options.

  • Context Menu
  • Action Menu

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

TriggerDescriptionUse Case
Button triggerA visible button (often with a chevron icon ▾) opens the menuPrimary pattern — toolbars, page headers
Icon-only triggerA compact icon button (⋯ or ⋮) opens the menuTable rows, cards, tight layouts. Always add aria-label.
Right-click / Context menuMenu appears at the cursor position on right-clickFile managers, canvas apps, IDE-style interfaces
Composite triggerPart of a Split Button — default action + menuWhen there's a clear primary action with alternatives

Menu Content Variants

VariantDescription
SimpleFlat list of menu items, each triggering an action
GroupedItems separated by dividers into logical groups (e.g., "Edit" group, "Danger" group)
With iconsEach item has a leading icon for visual scanning
With shortcutsKeyboard shortcut labels right-aligned (e.g., ⌘C, Ctrl+V)
With submenusNested menus that open on hover/arrow-key, indicated by a right-pointing chevron
With checkmarksItems that toggle state, showing a checkmark when active (like view options)
With radio itemsMutually exclusive options within a group (e.g., sort direction)
Destructive itemsRed-styled items for dangerous actions (delete, revoke). Always place last in a group, separated by a divider.

Size Variants

SizeMin WidthItem HeightFont SizeUse Case
Small160px32px13pxDense UIs, nested submenus
Medium200px36px14pxDefault for most interfaces
Large240px44px15pxTouch devices, spacious layouts

Properties

Dropdown Menu Properties

PropertyTypeDefaultDescription
openbooleanfalseControlled open state
onOpenChange(open: boolean) => void—Callback when menu opens or closes
triggerReactNode—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
sideOffsetnumber4Pixel gap between trigger and menu
modalbooleantrueWhether the menu behaves modally (traps pointer events outside)
loopbooleanfalseWhether keyboard navigation loops from last item to first

Menu Item Properties

PropertyTypeDefaultDescription
disabledbooleanfalsePrevents interaction and greys out the item
onSelect() => void—Callback when the item is selected
destructivebooleanfalseApplies destructive (red) styling
shortcutstring—Keyboard shortcut text displayed right-aligned
iconReactNode—Leading icon

Sub-Menu Properties

PropertyTypeDefaultDescription
labelstring—Submenu trigger label
disabledbooleanfalsePrevents submenu from opening

Token Mappings

Design Token Mappings

Token CategoryToken ExampleMenu Usage
Color – Surface--color-surface-elevatedMenu panel background
Color – Item Hover--color-surface-hoverHovered menu item background
Color – Item Active--color-primary-100Focused/active item background
Color – Text--color-text-primaryMenu item label
Color – Text Muted--color-text-tertiaryShortcut labels, descriptions
Color – Destructive--color-error-600Destructive item text and icon
Color – Divider--color-border-subtleGroup separators
Border Radius--radius-lg (12px)Menu panel corners
Border Radius – Item--radius-sm (4px)Individual item hover radius
Shadow--shadow-lgMenu 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-normalItem text
Transition--duration-fast (100ms), --ease-outOpen/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

StateBehavior
ClosedMenu is not rendered (or hidden). Trigger is in default state.
OpeningMenu fades in and optionally scales from origin point. Use Animation Generator for spring curves. Duration: 100–150ms.
OpenMenu visible. First item optionally receives focus. Clicking outside or pressing Escape closes.
ClosingReverse animation, 75–100ms. Faster than opening for responsive feel.

Menu Item States

StateVisual Treatment
DefaultNormal text color, transparent background
Hover / FocusedBackground highlight (--color-surface-hover). Rounded corners on the highlight.
Active / PressedSlightly darker background. Brief flash before the menu closes.
DisabledReduced opacity (0.5). cursor: not-allowed. Item is skipped in keyboard navigation.
DestructiveText and icon in --color-error-600. Hover background uses --color-error-50.
CheckedCheckmark icon visible. For toggle items (role="menuitemcheckbox").

Submenu States

StateBehavior
Trigger hoverAfter a brief delay (50–100ms), the submenu opens. Immediate open on arrow-right key.
Hover intentA "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 openParent menu item stays highlighted. Submenu appears to the right (or left if insufficient space).
Submenu closingBrief delay (150ms) before closing when focus leaves — prevents flicker on diagonal mouse movement.

Accessibility

WCAG Requirements

CriterionLevelRequirement
SC 1.4.3 Contrast (Minimum)AAMenu 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 ContrastAAFocus indicator, icons, and dividers need 3:1 contrast against adjacent colors.
SC 2.1.1 KeyboardAAll menu items and submenus must be fully operable via keyboard.
SC 2.4.3 Focus OrderAFocus must move logically through menu items and into/out of submenus.
SC 2.4.7 Focus VisibleAAActive/focused menu item must have a visible indicator.
SC 4.1.2 Name, Role, ValueAMenu 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", id matching the trigger's aria-controls
  • Menu items: role="menuitem"
  • Checkbox items: role="menuitemcheckbox", aria-checked="true|false"
  • Radio items: role="menuitemradio", aria-checked="true|false" within a role="group" with aria-label
  • Submenu trigger: role="menuitem", aria-haspopup="menu", aria-expanded="true|false"
  • Separators: role="separator"
  • Disabled items: aria-disabled="true" (not the disabled attribute — keep them discoverable)

Keyboard Interaction

KeyAction
Enter / SpaceOpens menu from trigger; activates focused menu item
Arrow DownOpens menu from trigger (if closed); moves focus to next item
Arrow UpMoves focus to previous item. From first item, moves to last (if loop).
Arrow RightOpens submenu (when focus is on a submenu trigger); moves into it
Arrow LeftCloses current submenu; returns focus to parent menu trigger
HomeMoves focus to first menu item
EndMoves focus to last menu item
EscapeCloses 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

html
<!-- 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>
tsx
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

FeatureMaterial 3Shadcn/uiRadixAnt Design
ComponentMenu (Exposed Dropdown Menu)DropdownMenu (wraps Radix)DropdownMenu primitiveDropdown (with Menu)
SubmenusNot built-inFull support via RadixDropdownMenu.SubMenu with nested SubMenu
Checkbox itemsNot built-inDropdownMenuCheckboxItemCheckboxItem primitiveNot built-in
Radio itemsNot built-inDropdownMenuRadioGroupRadioGroup + RadioItemNot built-in
Keyboard navigationFull arrow-key supportFull (Radix)Gold standard — type-ahead, Home/End, loopsArrow keys, Enter
AnimationMaterial motion curvesTailwind CSS transitionsBYO animation (CSS or Framer)Ant Motion (slide + fade)
PositioningManual or anchor-basedRadix's Popper (collision-aware)Popper engine — flip, shift, resizeTrigger-aligned dropdown
Context menuNot distinctSeparate ContextMenu componentContextMenu primitiveVia 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.

OverlayContext MenuAction Menu