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 hidden or equivalent).
  • Run axe-core — check for aria-required-children and button-name violations.