Accessible dropdowns
Live demo
"Dropdown" covers two very different patterns with different ARIA roles, keyboard models, and accessibility requirements. Choosing the wrong one — or building a custom widget when a native element works — is one of the most common accessibility failures on government and nonprofit sites.
Two types of dropdowns
Type 1 — Select dropdowns (form fields)
Used when a user must choose a value that will be submitted as form data — state, language, category. Use the native <select> element. It is fully keyboard accessible, fully screen reader compatible, and works on every device and browser with zero JavaScript.
Type 2 — Disclosure menus (navigation / action menus)
Used when clicking a button reveals a list of links or actions — a navigation mega-menu, an overflow "More actions" button, or a user avatar menu. This requires a custom ARIA implementation using role="menu" and role="menuitem".
Type 1: native <select>
Always prefer the native <select> element for form fields. Custom-styled select replacements are almost always less accessible and require significant engineering to match native behavior.
<!-- Always associate a <label> — never use placeholder as the only label -->
<label for="state-select">State</label>
<select id="state-select" name="state">
<option value="">-- Select a state --</option>
<option value="CA">California</option>
<option value="NY">New York</option>
<option value="TX">Texas</option>
</select>
<!-- Multi-select: add size attribute so options are visible -->
<label for="services-select">Services needed (select all that apply)</label>
<select id="services-select" name="services" multiple size="4">
<option value="housing">Housing assistance</option>
<option value="food">Food assistance</option>
<option value="legal">Legal aid</option>
<option value="childcare">Childcare</option>
</select>Native <select> keyboard support is built in: arrow keys navigate options, Enter/Space select, and the first letter jumps to the matching option. No JavaScript required.
Type 2: custom disclosure menu
When you need a button that reveals a list of links or actions, use the disclosure button pattern with aria-expanded and aria-haspopup="true" on the trigger, and role="menu" with role="menuitem" on the list.
<div class="menu-container">
<button
id="menu-trigger"
aria-expanded="false"
aria-haspopup="true"
aria-controls="action-menu"
class="menu-trigger"
>
Actions
<span aria-hidden="true">▾</span>
</button>
<ul
id="action-menu"
role="menu"
aria-labelledby="menu-trigger"
hidden
>
<li role="none">
<a role="menuitem" href="/edit">Edit application</a>
</li>
<li role="none">
<a role="menuitem" href="/download">Download PDF</a>
</li>
<li role="none">
<button role="menuitem" type="button" class="menu-item-btn">
Delete
</button>
</li>
</ul>
</div>Note the role="none" on the <li> elements — this removes the implicit list item role, which would conflict with the menu/menuitem structure that assistive technology expects.
Keyboard interaction
- Enter or Space on trigger — opens the menu and moves focus to the first menu item.
- Escape — closes the menu and returns focus to the trigger button.
- Down arrow — moves focus to the next menu item (wraps to first at end).
- Up arrow — moves focus to the previous menu item (wraps to last at start).
- Home — moves focus to first menu item.
- End — moves focus to last menu item.
- Tab — closes the menu and moves focus to the next focusable element (menu items are not in the tab order while closed).
- First character typeahead — pressing a letter jumps focus to the first menu item starting with that letter (optional but strongly recommended for long menus).
Focus management JavaScript
const trigger = document.getElementById('menu-trigger');
const menu = document.getElementById('action-menu');
const items = () => Array.from(menu.querySelectorAll('[role="menuitem"]'));
function openMenu() {
trigger.setAttribute('aria-expanded', 'true');
menu.removeAttribute('hidden');
items()[0].focus();
}
function closeMenu() {
trigger.setAttribute('aria-expanded', 'false');
menu.setAttribute('hidden', '');
trigger.focus(); // return focus to trigger
}
trigger.addEventListener('click', () => {
trigger.getAttribute('aria-expanded') === 'true' ? closeMenu() : openMenu();
});
menu.addEventListener('keydown', (e) => {
const all = items();
const idx = all.indexOf(document.activeElement);
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); all[(idx + 1) % all.length].focus(); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); all[(idx - 1 + all.length) % all.length].focus(); return; }
if (e.key === 'Home') { e.preventDefault(); all[0].focus(); return; }
if (e.key === 'End') { e.preventDefault(); all[all.length - 1].focus(); return; }
if (e.key === 'Tab') { closeMenu(); }
});
// Close on outside click
document.addEventListener('click', (e) => {
if (!trigger.contains(e.target) && !menu.contains(e.target)) {
if (trigger.getAttribute('aria-expanded') === 'true') closeMenu();
}
});Warning: avoid role="combobox" unless necessary
Warning: Do not use role="combobox" unless you are building a full combobox — a text input paired with a filtered list of suggestions (autocomplete). The combobox pattern requires managing aria-activedescendant, live regions, and a precise keyboard model across multiple states (closed, open, no-results). A mistake in any of these makes the widget completely unusable for screen reader users. If your requirement is a simple select field or a disclosure menu, use those simpler patterns instead.
Required WCAG success criteria
- SC 4.1.2 Name, Role, Value (A) — the trigger must announce its role (button), its label, and its state (expanded or collapsed). Menu items must have
role="menuitem". - SC 2.1.1 Keyboard (A) — all menu items must be reachable and activatable by keyboard. Arrow keys navigate; Escape closes.
- SC 2.4.3 Focus Order (A) — when the menu closes, focus must return to the trigger button, not be lost to the top of the page.
- SC 2.4.7 Focus Visible (AA) — the focused menu item must have a visible focus indicator with sufficient contrast.
Testing checklist
- Tab to the trigger — confirm it is announced as "button, collapsed" (or equivalent). Press Enter — confirm menu opens and focus moves to first item.
- Arrow through all items — confirm focus moves correctly and wraps. Press Escape — confirm menu closes and focus returns to trigger.
- Click outside the menu — confirm it closes without losing page focus entirely.
- With VoiceOver (Safari) and NVDA (Firefox) — navigate into the menu; confirm items announce as "menu item" and the menu itself is announced as "menu".
- Verify hidden menu items are not reachable by Tab while the menu is closed (they must carry
hiddenor equivalent). - Run axe-core — check for
aria-required-childrenandbutton-nameviolations.
