Dropdown menu

A list of commands, anchored to the control that opened it. The overlay is the browser’s, not ours.

Try it

Open it with a click, or with ↓ to land on the first row and ↑ to land on the last.

Choosing a menu

A menu runs a command. When the reader is choosing a value that stays in a field, it is a Select; when a row leads somewhere, it is navigation and wants links, not role="menu". A menu belongs on a control that holds more actions than a row or a header has room for — the trailing dots of a table row, the actions of a record — and not in place of a button the reader would press every time.

Keep it to a handful of rows, grouped with a label when the groups mean something, with a separator only between groups. Labels start with the verb and name the object the way the page does: Edit appointment, Reschedule. The one command that creates takes the accent; the one that destroys takes danger and goes last, so a hand travelling down the list meets it after everything safe. A command that cannot run now stays in the list, disabled, so the reader learns it exists.

Why both words

The drawing calls this page Dropdown, and “dropdown” alone covers two components with different semantics: a list of commands, which is this one, and a list of values. Seven of the fifteen drawn variants belong to the second. Naming this one after the umbrella would leave the other one homeless. A single value from a fixed list is already a Select; a richer list of values would be a listbox, and is not built.

“Menu” alone is no better. It is also what a site’s navigation is called, and navigation menus are planned — a different pattern, where a link is followed rather than a command run, and for which role="menu" is the wrong role. “Dropdown menu” names both halves: how it appears, and what it holds. It is also the name most libraries give this pattern, so a reader arriving from one of them finds it where they expect.

Anatomy

A surface of surface/overlay at 8px of padding and a 12px corner, holding rows of 40px with an 8px corner, a 20px icon slot at either end, group labels in the caption size, and hairline separators. The surface is as wide as its widest row and held at that width, so the menu is the same size open and closed. In dark it takes a hairline border, because the shadow has stopped separating it — see the margin.

The trade

The surface is a native popover, placed with CSS anchor positioning. That gives the top layer — so it escapes overflow: hidden and every stacking context without a portal — plus dismissal on Esc and on an outside click, and focus returning to the trigger. There is no positioning JavaScript to keep correct on scroll and on resize, and no new dependency. It is the same trade Select makes with the native <select>.

The cost is stated rather than hidden: in a browser older than about 2025, the menu opens centred rather than anchored. It still dismisses, and it still takes the keyboard.

Keyboard

KeyOn the triggerIn the menu
Enter / Spaceopen, focus the first rowactivate, close
open, focus the first rownext, wrapping
open, focus the last rowprevious, wrapping
Home / Endfirst / last
a–ztypeahead on the first character
Escclose, focus returns to the trigger
Tabclose, tabbing continues

popover="auto" already provides Esc, the outside click and focus return. Four things remain the component’s: landing on the first row when the menu opens, opening from the trigger with the arrows, the roving focus, and closing on Tab — which popover does not do and the APG menu pattern asks for.

The pointer moves focus

Entering a row with the pointer focuses it. Without that there are two highlights at once — the row under the pointer and the row the keyboard holds — and neither answers “what happens if I press Enter now”. With it there is exactly one highlighted row, and it is always the one that will be activated. The fill is drawn on :focus rather than :hover for the same reason.

Where it departs from the drawing

  1. surface/overlay replaces surface/raised. The two are indistinguishable in light and one elevation step apart in dark, where raised would put the menu on the same step as the card beneath it.
  2. radius/xl and radius/lg replace the drawn lg and md. The two files’ radius names are off by one step; the numbers, 12 and 8, are unchanged.
  3. The hover fill follows the row’s tone instead of being surface/base for every row. surface/base is darker than the menu in dark, and the accent label on a shared opaque neutral fill was 3.50:1 there when the rule was made. The neutral row takes the wash now, on which the accent label would be 7.86:1, and the rule stays: each tone hovers to its own surface because that is the design.
  4. The danger row’s hover border is dropped. It would reflow the row by 1px and be the only hover in the system that changes geometry; the fill alone is unambiguous.
  5. In dark the surface takes a 1px border/default, which is not drawn. There the shadow has stopped carrying elevation — see the gutter — and a border is the system’s answer to running out of it.

Two additions the drawing does not contain. A disabled row: it is not focusable, takes no hover and is skipped by every key, but keeps its role so a screen reader still finds it. And the dark elevation, which was never drawn and is a decision rather than a reading.

What the suite does not see

jsdom, which runs the test suite, implements none of the popover API. The suite stubs the calls the component makes and asserts everything the component decides: the roles and their wiring, the rows, every key, the disabled rows, and the stylesheet’s choices of token. Esc, the outside click, focus return and placement belong to the browser, and all four were checked in Chrome.

That check found one defect the suite could not. The rule removing the default outline sat inside the guarded fill, one attribute more specific than the keyboard ring, so the ring lost: a keyboard user saw a fill and no ring. jsdom computes no :focus-visible, so the suite passed. The stylesheet test now pins the order of the two rules instead.

The top layer was worth that gap. A menu opens from inside other components — a row of actions in the Table, whose scroll container clips anything positioned inside it — and escaping that clip is the reason for an overlay to exist.

Accessibility

The trigger receives aria-haspopup="menu" and aria-expanded, and its label is the accessible name of the menu — so it is named for what the menu holds, Appointment actions, not More. The rows are menuitems inside a menu, one tab stop with roving focus, and a disabled row keeps its role with aria-disabled so a screen reader finds it rather than a hole.

The highlighted row is the focused row, whichever way it got there, so there is one answer to what Enter does. The keyboard ring is drawn on top of the fill; a stylesheet test pins the order of the two rules, after a browser check found the ring losing on specificity.

Props

DropdownMenu props
PropTypeDefault
trigger(props: DropdownMenuTriggerProps) => ReactNoderequired
itemsDropdownMenuEntry[]required

An entry is an action, a group — { label, items } — or the string 'separator'. An action:

Action props
PropTypeDefault
idstringrequired
labelReactNoderequired
onSelect() => void
tone'neutral' | 'accent' | 'danger''neutral'
iconReactNode
iconEndReactNode
disabledbooleanfalse
textValuestringthe label, when it is a string

Spread the trigger props onto a button; they carry the id, the popover target, the two aria attributes, the key handler and the anchor.