Combobox Pattern: Accessible Dropdowns With ARIA Code
Native select first. When you truly need a custom dropdown, this is the role structure, the keyboard map and the focus handling that make it usable.
Native select elements are accessible out of the box. The moment a design calls for searchable options, rich option content, grouped items or async loading, a team reimplements the browser's behaviour in JavaScript, and that is where it breaks. A custom dropdown that looks finished but announces nothing, traps focus or ignores the arrow keys is one of the most common findings in a client audit.
This is the pattern that works, the keyboard map it has to support, and the three mistakes that account for most broken dropdowns in the wild.
First, do not build one
Use the native select whenever the requirement allows it. It is accessible on every platform, it gives mobile users the system picker for free, and no amount of care in a custom widget will match it. Build a combobox only when you genuinely need something a native select cannot do: filtering as the user types, options containing more than text, or options loaded from the server.
That sentence is also the one to put in front of a client who wants a heavily styled dropdown. Most of the cost is not the styling. It is the keyboard and screen reader work underneath it.
The roles, and which element carries them
The W3C ARIA Authoring Practices Guide is specific: "The element that serves as an input and displays the combobox value has role combobox", with aria-expanded false when the popup is hidden and true when it is visible, and aria-controls "set to a value that refers to the element that serves as the popup" (APG, Combobox Pattern).
Two details save a lot of argument in review. The popup is the thing with role="listbox", not the trigger. And you do not need aria-haspopup="listbox", because "elements with role combobox have an implicit aria-haspopup value of listbox"; you only set it when the popup is a tree, grid or dialog instead.
<!-- Broken: a styled div, no role, no state, nothing to announce -->
<div class="select" onclick="toggle()">United States</div>
<ul class="options hidden">
<li onclick="pick(this)">United States</li>
<li onclick="pick(this)">United Kingdom</li>
</ul>
<!-- Fixed: the input carries the combobox role and its state -->
<label id="country-label" for="country">Country</label>
<input id="country" role="combobox" type="text"
aria-expanded="false"
aria-controls="country-list"
aria-activedescendant="">
<ul id="country-list" role="listbox" aria-labelledby="country-label" hidden>
<li id="c-us" role="option" aria-selected="true">United States</li>
<li id="c-uk" role="option" aria-selected="false">United Kingdom</li>
</ul>
A screen reader user should hear the label, the role and the current value, in the order "Country, combobox, United States", not "collapsed" on its own. If the name is missing, that is WCAG 4.1.2 Name, Role, Value, Level A. Our ARIA checker flags the machine-detectable half of this, and when and how to use ARIA attributes covers the wider rules.
The keyboard map
Every one of these has to work, and this is where most custom dropdowns fail. WCAG 2.1.1 Keyboard, Level A requires that "all functionality of the content is operable through a keyboard interface" (WCAG 2.2).
| Key | Expected behaviour |
|---|---|
| Enter or Space | Open the listbox |
| Arrow Down | Open it, or move to the next option |
| Arrow Up | Move to the previous option |
| Home and End | Jump to the first and last option |
| A letter key | Typeahead to the next matching option |
| Enter | Select the active option and close |
| Escape | Close without selecting, focus back on the combobox |
| Tab | Close and move to the next control on the page |
Note what is not in the list: Tab does not walk through the options. The APG rule for any composite widget is that "the tab sequence should include only one focusable element of a composite UI component", with the arrow keys moving inside it (APG, Developing a Keyboard Interface). A dropdown with forty options should be one tab stop, not forty. Our keyboard navigation guide covers why.
Where focus actually lives
This is the subtle one, and the mistake we find most often. DOM focus does not move into the list. The APG says it directly: "When a descendant of a listbox, grid, or tree popup is focused, DOM focus remains on the combobox and the combobox has aria-activedescendant set to a value that refers to the focused element within the popup."
Moving real focus into the options breaks typeahead, because the key handlers live on the combobox, and it confuses screen readers about what the user is editing.
// Broken: real focus jumps into the list, typeahead stops working
function highlight(option) {
option.focus();
}
// Fixed: focus stays on the combobox, the active option is pointed at
function highlight(option) {
combobox.setAttribute('aria-activedescendant', option.id);
listbox.querySelectorAll('[role="option"]')
.forEach(o => o.setAttribute('aria-selected', String(o === option)));
option.scrollIntoView({ block: 'nearest' });
}
The highlighted option still needs to be visible, and the combobox itself still needs a focus ring. Styling the highlight but deleting the outline is a 2.4.7 Focus Visible, Level AA failure that testers miss because the widget looks like it is working.
Closing, and giving focus back
Close the listbox on Escape, on selection, on outside click and on blur. On Escape and on selection, focus returns to the combobox. If focus ends up at the top of the document instead, the user has to tab back through the whole page, which is a 2.4.3 Focus Order, Level A finding: focusable components must "receive focus in an order that preserves meaning and operability".
The same discipline applies to dialogs, and for the same reason. Our guide to accessible modals has the focus return pattern in full.
One more thing to leave alone: do not add an aria-live region announcing the open state or the active option. Correctly wired aria-expanded and aria-activedescendant already produce that announcement, and a live region on top of them makes the screen reader say everything twice.
How to test one in ten minutes
Put the mouse away and work through this. Our step by step guide to testing keyboard accessibility by hand covers the wider pass; this is the dropdown-specific part of it.
- Tab to the dropdown. It is one stop, and you can see the focus ring.
- Open it with Enter, then with Arrow Down. Both work.
- Arrow through every option. The highlight moves and stays in view.
- Press a letter. Typeahead jumps to a matching option.
- Press Escape. The list closes and focus is back on the dropdown.
- Open it and press Tab. The list closes and focus moves on, without trapping you.
- With a screen reader on, confirm you hear the label, the role, the state and the active option.
- On a phone, check it with VoiceOver on iOS or TalkBack on Android. Touch and screen reader gestures are a separate pass.
Our keyboard and focus checker and ARIA checker catch the parts a machine can see, such as a missing accessible name or an aria-controls that points at nothing, across every page you add. Run a free accessibility scan to see what a client's current dropdowns report. What no automated tool can do, here least of all, is confirm that the widget conforms to WCAG: a combobox passes or fails on behaviour, and behaviour needs a person with a keyboard.