Dropdown menu

Keeps the actions for one thing out of sight until asked for.

Examples

Ticket actions

From an icon

View options

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) => void
DropdownMenuTrigger.selectable
boolean default false
DropdownMenuItem variant
'default' | 'destructive' default 'default'
DropdownMenuItem.inset
boolean default false
DropdownMenuLinkItem.href
string
DropdownMenuCheckboxItem / RadioItem
Base 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)

Install

For your coding agent

GitHub

Please star me pookie

141

A new design system every week

Leave your email before you copy the prompt.