Accessibility · Accessible components
Accessible menus & comboboxes
⚡ TL;DR — "Menu" and "combobox" are specific ARIA patterns, not "any dropdown." A
menu/menuitemis an application menu navigated with arrow keys where Tab exits the whole widget; acomboboxis a text input with an attached popup of options. Pick the wrong pattern and you promise the screen-reader user keyboard behaviour your widget doesn't deliver — which is worse than plain HTML.
🧠 Mental model
The most important decision is made before any ARIA: which pattern is this actually? Three things get called "dropdown" and they are not interchangeable:
| It's really a… | Use | Keyboard contract |
|---|---|---|
| Navigation / list of links | <ul><li><a> — no menu role |
native Tab |
| Form field picking one value | native <select>, or combobox |
arrows open + move |
| App commands (Cut/Copy/Paste) | menu + menuitem |
arrows move, Tab exits |
ARIA roles are promises about behaviour. role="menu" tells assistive tech "arrow keys move between items and Tab leaves the menu entirely." If you slap that on a set of nav links, the user presses Tab expecting to move to the next link and instead lands outside the nav — you've broken their mental model. The senior instinct is to downgrade: a site nav dropdown is not a menu, it's a disclosure (button[aria-expanded]) revealing a list of links. Real menu/menuitem is for application command menus, which are rare on the web.
⚙️ How it actually works
The menu button pattern. A button with aria-haspopup="menu" and aria-expanded toggles a role="menu" containing role="menuitem" children. Only one item is in the tab order at a time — the widget uses roving tabindex (active item tabindex="0", the rest -1) so Tab jumps clean out of the menu, and Up/Down move within it. Esc closes and returns focus to the button; Home/End jump to first/last; typing a letter does typeahead.
The combobox pattern (APG 1.2, the modern one) is an <input role="combobox"> with aria-expanded, aria-controls pointing at a role="listbox", and aria-autocomplete describing behaviour. Focus stays in the input the entire time — you never move DOM focus into the list. Instead you use aria-activedescendant: the input carries aria-activedescendant="opt-3" naming the visually-highlighted role="option", and AT announces that option while the input keeps the caret. This split — focus in the input, "virtual focus" in the list — is the crux of combobox accessibility and the thing candidates most often miss.
Two techniques, know when each applies:
- Roving tabindex — real DOM focus moves; used by menus, tabs, radio groups, grids.
aria-activedescendant— DOM focus is fixed, a pointer names the active child; used by comboboxes where you must keep typing into the input.
💻 Code
Combobox skeleton — note focus never leaves the input:
<label id="lbl">Country</label>
<input role="combobox" aria-labelledby="lbl"
aria-expanded="true" aria-controls="opts"
aria-autocomplete="list" aria-activedescendant="opt-2" />
<ul id="opts" role="listbox" aria-labelledby="lbl">
<li id="opt-1" role="option">Canada</li>
<li id="opt-2" role="option" aria-selected="true">Chad</li>
<li id="opt-3" role="option">Chile</li>
</ul>
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
// move the POINTER, not focus — input keeps the caret
active = next(active);
input.setAttribute('aria-activedescendant', active.id);
active.scrollIntoView({ block: 'nearest' });
e.preventDefault();
}
if (e.key === 'Escape') closeAndClear();
if (e.key === 'Enter') commit(active);
});
<!-- ❌ nav links wearing a menu role: Tab now escapes the whole nav -->
<ul role="menu">
<li role="menuitem"><a href="/pricing">Pricing</a></li>
</ul>
<!-- ✅ it's a disclosure over links, not an application menu -->
<button aria-expanded="false" aria-controls="nav">Products ▾</button>
<ul id="nav" hidden><li><a href="/pricing">Pricing</a></li></ul>
⚖️ Trade-offs
- Native
<select>beats a combobox 90% of the time. It's fully accessible, works on mobile, and needs zero JS. Only build a combobox when you truly need typeahead filtering or custom option rendering — you're taking on the entire keyboard spec by hand. menu/menuitemis almost never right on a website. It's for app command menus. When in doubt, use a disclosure + list of links or buttons; you lose nothing and avoid a broken keyboard contract.aria-activedescendantvs roving tabindex isn't a preference — it's dictated by whether focus must stay put. Comboboxes require the input to keep focus, so they must use activedescendant.
💣 Gotchas interviewers probe
- Using
role="menu"for site navigation. The classic mistake — it changes the Tab contract and confuses users. Nav dropdowns are disclosures, not menus. - Moving DOM focus into the listbox of a combobox. Then the user can't keep typing. Focus stays in the input; the list is driven by
aria-activedescendant. menuitemchildren must be only menuitems (ormenuitemcheckbox/menuitemradio). Wrapping them in extra divs with roles, or putting arbitrary links inside, breaks the pattern.- Forgetting to keep
aria-expandedin sync. AT reads it to announce open/closed; a stale value lies to the user. - No
aria-selected/active option management means the announced option and the highlighted option drift apart. aria-haspopupvalue must match the popup type (menu,listbox,dialog…) —"true"legacy-maps tomenu, which may be wrong.
🎯 Say this in the interview
"The first thing I do is classify the widget, because 'menu' and 'combobox' are specific ARIA contracts, not generic dropdowns. If it's site navigation, it's a disclosure — a button with
aria-expandedrevealing a list of links — notrole=\"menu\", because a real menu changes the Tab behaviour and users don't expect that on a webpage.role=\"menu\"is for application command menus. If it's a form field with typeahead, it's a combobox: an input withrole=\"combobox\",aria-controlspointing at a listbox, and crucially the DOM focus stays in the input the whole time — I drive the highlighted option witharia-activedescendant, so the user can keep typing while assistive tech announces the active option. That's the split people miss: roving tabindex moves real focus for menus and tabs, but comboboxes keep focus in the input and use a virtual pointer. And honestly, if a native<select>does the job, I use it."
🔗 Go deeper
- ARIA APG — Combobox Pattern — the canonical spec, including the activedescendant model.
- ARIA APG — Menu & Menu Button Pattern — when a real menu is appropriate and its full keyboard map.
- MDN — aria-activedescendant — the "virtual focus" mechanism.
- Adrian Roselli — Disclosure Widgets — why most "menus" should be disclosures.