Dropdown menu
Keeps the actions for one thing out of sight until asked for.
Examples
Item
itemdefault
destructive
checkbox item
radio item
submenu
States
closed
open
highlighted
disabled
Usage
When to use it
- Use
- Three or more actions on one item compete for a row.
- Use
- A row or canvas where people expect a right click, with the same actions on a visible button.
- Use
- A few view settings toggle: columns, density, sort.
- Not for
- One or two actions. Use Button.
- Not for
- Picking a value for a form field. Use Select.
- Not for
- Finding one of many commands by name. Use Command.
Rules
10 rules
- Must
- Put the same actions behind a visible More button on every row that has a contextmenu trigger. Because a right click or long press is invisible, so a person who never tries one never finds the actions.
- Must
- Put a destructive item last, after a separator, with variant="destructive". Because a destructive item first sits under the pointer and the first arrow press, one slip from Delete.
- Must
- Use DropdownMenuLinkItem for an item that navigates, never an item with router.push. Because router.push leaves a menuitem with no href, so open in new tab and copy link do nothing.
- Should
- Pass selectable to a context trigger that covers text people copy.
- Must
- Name an icon trigger with the object it acts on. Because every row's trigger reads "More", so a screen reader cannot tell which row it opens.
- Should
- Start each item with a verb in sentence case and name the object when the menu covers several.
- Should
- Show only shortcuts that work on the page now.
- Should
- Give every item in a group an icon or none, only from the allowed action set (see Iconography).
- Never
- Color an item red by className; use variant="destructive". Because a raw red skips the fail tokens, so its contrast on the highlight wash and in dark theme goes unchecked.
- Should
- Group a menu past 12 items with labels, or move it to a Command list with search.
Accessibility
6 checks
- Enter, Space or Down on the trigger opens with the first item highlighted; Shift+F10 opens a context menu.
- Arrows move the highlight, a letter jumps to the next match, Escape closes this menu only and returns focus to the trigger.
- Right and Left open and close a submenu, mirrored in RTL.
- The trigger carries aria-haspopup="menu" and aria-expanded; the popup is role="menu".
- A disabled item stays reachable by arrows so its label can be read; Enter does nothing.
- Checkbox and radio items render menuitemcheckbox and menuitemradio with aria-checked.
Edge cases
10 edge cases
- Long item label
- Wraps inside the panel, which stops at 480px or the available width.
- Right to left
- The indicator column, shortcut and caret move to logical sides.
- Touch
- Items grow to 44px; a long press opens the context menu without selecting a word.
- Sixty items
- The panel scrolls inside the space below the trigger and flips or shifts 8px at each viewport edge.
- One item
- Opens at the 192px minimum; use a Button instead.
- No items
- Do not render the trigger; an empty menu opens an 8px padded box.
- Inside a Dialog
- Escape closes the menu only.
- Failed action
- Report it in a Toast after the menu closed; the menu has no error state.
- Forced colors
- Items take Highlight, disabled takes GrayText, the marked row gets a 2px outline.
- Reduced motion
- The fade cuts; the menu never grows.
API
7 props
DropdownMenu.trigger'click' | 'contextmenu'default'click'DropdownMenu.onTargetChange(row: HTMLElement | null) => voidDropdownMenuTrigger.selectablebooleandefaultfalseDropdownMenuItem variant'default' | 'destructive'default'default'DropdownMenuItem.insetbooleandefaultfalseDropdownMenuLinkItem.hrefstringDropdownMenuCheckboxItem / RadioItemBase UI
Tokens
20 tokens
--surface-page- var(--paper)
--elev-overlay- 0 0 0 1px var(--line-frame), 0 12px 24px -12px oklch(var(--text-on-field-c) / var(--shadow-alpha))
--radius-frame- 10px
--radius-inner- 4px
--space-1- 4px
--menu-min- 192px
--overlay- 480px
--dur-base- 160ms
--dur-fast- 120ms
--ease-step- cubic-bezier(0.2, 0, 0, 1)
--type-label- 400 14px/16px var(--font-sans)
--type-label-sm- 400 12px/16px var(--font-sans)
--text-1- var(--ink)
--text-3- oklch(var(--ink-c) / 0.66)
--text-disabled- oklch(var(--ink-c) / 0.40)
--surface-hover- oklch(var(--ink-c) / 0.05)
--accent-wash- var(--surface-selected)
--mark-size- 8px
--ink- oklch(var(--ink-c))
--line-row- oklch(var(--ink-c) / 0.08)