Combobox & autocomplete
A combobox is a text input paired with a filtered list of suggestions: the user
types, the list narrows, and they can pick an entry with the keyboard or pointer
without losing the ability to type freely. It is one of the more involved widgets to
get right, because focus has to stay in the input while a separate listbox is
navigated — so reach for a plain <select>, or an
<input list> tied to a <datalist>, whenever they
cover the need. Build this richer combobox only when you genuinely need filtering,
custom option rendering, or behaviour the native controls can’t give you.
The widget below follows the WAI-ARIA
Authoring Practices editable-combobox pattern with list autocomplete. The page is a
reference implementation: the markup is authored statically, and
combobox.js enhances it — with scripting off, the field is still a
labelled text input.
Live demo
Focus the field or press Down Arrow to open the list, type to filter, use
the arrow keys to move the highlight, and press Enter to choose. Notice that
your typing cursor never leaves the input — the highlighted option is tracked with
aria-activedescendant, not by moving focus.
- Perceivable
- Operable
- Understandable
- Robust
- Text alternatives
- Keyboard accessible
- Enough time
- Navigable
- Readable
- Predictable
- Compatible
When to use
Prefer the native control first
- If the user just picks one value from a fixed, short list, use a
<select>— it is keyboard- and screen-reader-complete for free. - If you want type-ahead suggestions over a list but don’t need custom
rendering or filtering control, try
<input list="…">with a<datalist>. - Build this ARIA combobox when you need filtering as the user types, custom option markup, async results, or behaviour the native widgets can’t express — and budget for the keyboard model below.
- Whatever you choose, the field must keep a real, visible
<label>tied to it byfor/id.
Markup
Author the input and listbox statically with their ARIA wiring already in place, then
let combobox.js manage state. The script adds option ids if
missing, but providing them keeps the markup self-describing.
<div class="ewa-combobox" data-combobox>
<label class="ewa-field__label" id="cb-label" for="cb-input">Choose a WCAG principle</label>
<input class="ewa-input ewa-combobox__input" id="cb-input" type="text"
role="combobox" aria-expanded="false" aria-controls="cb-listbox"
aria-autocomplete="list" autocomplete="off">
<ul class="ewa-combobox__listbox" id="cb-listbox" role="listbox"
aria-labelledby="cb-label" hidden>
<li class="ewa-combobox__option" id="cb-opt-0" role="option">Perceivable</li>
<li class="ewa-combobox__option" id="cb-opt-1" role="option">Operable</li>
<li class="ewa-combobox__option" id="cb-opt-2" role="option">Understandable</li>
<li class="ewa-combobox__option" id="cb-opt-3" role="option">Robust</li>
<!-- …more options… -->
</ul>
</div>
Keyboard interactions
Focus stays on the input throughout. The keys below act on the listbox while the caret stays put, so a keyboard or screen-reader user drives everything from the field.
| Key | When | Result |
|---|---|---|
| Down Arrow | List closed | Opens the list and moves the highlight to the first option. |
| Down Arrow | List open | Moves the highlight to the next visible option; wraps from last to first. |
| Up Arrow | List open | Moves the highlight to the previous visible option; wraps from first to last. |
| Enter | An option is highlighted | Selects it: its text fills the input, the list closes, focus stays on the input. |
| Esc | List open | Closes the list and clears the highlight without changing the typed value. |
| Typing / editing | Any time | Filters options to those containing the text (case-insensitive); a “No matches” row shows when none match. |
| Home / End | List open (optional) | Jumps the highlight to the first / last visible option. |
ARIA roles, states, and properties
The input owns the combobox role and the open/active state; the popup is a listbox of options. The active option is referenced from the input, never focused in the DOM.
| Attribute | On | Purpose |
|---|---|---|
role="combobox" |
Input | Identifies the text field as a combobox that controls a popup. |
role="listbox" |
Popup <ul> |
Marks the suggestion popup as a list of selectable options. |
role="option" |
Each <li> |
Marks each suggestion as a selectable option within the listbox. |
aria-expanded |
Input | true while the listbox is shown, false when closed. |
aria-controls |
Input | Points at the listbox’s id so AT knows what the input drives. |
aria-activedescendant |
Input | Holds the id of the highlighted option; always a visible option, or absent. |
aria-autocomplete="list" |
Input | Tells AT that typing filters a list of suggestions (not inline text completion). |
aria-selected="true" |
Active option | Marks the single highlighted option; removed from all others. |
aria-labelledby |
Listbox | Names the popup from the same visible label as the field. |
Common mistakes
Pitfalls to avoid
- Moving DOM focus into the options. Focus must stay on the
input; track the highlight with
aria-activedescendant, or the typing cursor and the screen-reader announcement fall out of sync. - Pointing
aria-activedescendantat a hidden option. After filtering, the referenced id must still be a visible, selectable option — otherwise remove the attribute entirely. - Making the “No matches” row selectable. It should be
aria-disabledand never become the active descendant. - Closing the list on the option
click’s blur. Commit the choice onmousedown(withpreventDefault) so the input’s blur doesn’t close the popup before the click lands. - Relying on colour alone for the highlight. Pair the active
background with a second cue and remap to
Highlight/HighlightTextunder forced colours. - Dropping the visible label. A placeholder is not a label; keep a
real
<label for>on the input.