Accessible Combobox & Autocomplete Guide
Autocomplete is the most misused pattern in ARIA. This guide covers role="combobox" on the input, aria-expanded, aria-autocomplete, and the aria-activedescendant focus model that keeps focus in the field while the arrow keys move a virtual highlight — with copy-ready HTML, JavaScript, and React mapped to WCAG 2.2.
Why the Combobox Is the Hardest ARIA Pattern to Get Right
A combobox is a text input joined to a popup list of suggestions — the pattern behind every search autocomplete, typeahead, tag picker, and country selector on the web. It looks like a search box with a dropdown, so most teams build it like one: a <div> of results under an input, wired with click handlers. That version works for a mouse and is invisible to everyone else. The keyboard cannot reach the suggestions, the screen reader never learns the popup opened, and the number of results is announced to no one.
What makes the combobox genuinely hard is its focus model. Unlike tabs or a menu, focus must never leave the input — the user has to keep typing while moving through options. The pattern solves this with aria-activedescendant: a virtual focus that highlights an option without the caret ever leaving the field. Get that one idea right and the rest falls into place. The WAI-ARIA Authoring Practices Combobox pattern defines the roles, states, and keys; this guide turns them into code you can paste in — and shows you when to skip ARIA and let the browser do the work.
Not everything with a dropdown is a combobox. A search field that just submits a query is a search box — leave the combobox roles off it. Add them only when there is a real, navigable popup of options. Want a live, hands-on version? Try the interactive accessible search & combobox demo.
The WCAG 2.2 Criteria a Combobox Must Satisfy
| Criterion | Level | What the combobox must do |
|---|---|---|
| 1.3.1 Info & Relationships | A | The input is a combobox, the popup is a listbox, and options are options — all set programmatically. |
| 2.1.1 Keyboard | A | Open, filter, move through options, select, and dismiss — all from the keyboard alone. |
| 2.4.3 Focus Order | A | Focus stays in the input; the popup does not trap or scatter focus. |
| 2.4.7 Focus Visible | AA | The input shows a focus ring, and the active option has a clearly visible highlight. |
| 4.1.2 Name, Role, Value | A | The combobox exposes its name, role, expanded state, and active-option value. |
| 4.1.3 Status Messages | AA | The number of available suggestions is announced without moving focus. |
See the full picture in the WCAG 2.2 Level AA requirements and work through the interactive WCAG 2.2 checklist.
1. Consider the Native <datalist> First
There is no fully native combobox, but there is a native autocomplete: an <input> with a linked <datalist>. The browser renders and manages the suggestion list, handles the keyboard, and exposes it to assistive technology — with no JavaScript and no ARIA. For simple “suggest from a list of plain strings” cases, start here.
<!-- Native autocomplete: zero JavaScript, zero ARIA -->
<label for="country">Country</label>
<input id="country" name="country" list="country-options" />
<datalist id="country-options">
<option value="Australia"></option>
<option value="Brazil"></option>
<option value="Canada"></option>
<option value="Denmark"></option>
</datalist>When <datalist> is not enough
- You need to style the dropdown or its options —
<datalist>is almost entirely unstyleable. - Options need rich content — two lines, an icon, a category heading, a “no results” state.
- Suggestions are fetched asynchronously as the user types.
- You need consistent, predictable behavior across browsers and screen readers, which
<datalist>does not guarantee.
When those apply, build the ARIA combobox below — and match the native behavior as closely as you can.
2. Anatomy: The Roles, States, and Properties
An ARIA combobox has four parts: the input, the popup, the options, and a separate live region that announces the result count. The most important detail — the one that broke in older tutorials — is that role="combobox" goes on the input itself, not on a wrapper.
| Element | Role | Key attributes |
|---|---|---|
| The text input | role="combobox" | aria-expanded="true|false", aria-controls="<listbox-id>", aria-autocomplete="none|list|both", aria-activedescendant="<active-option-id>" when open. Labelled with <label for> or aria-labelledby. |
| The popup | role="listbox" | A unique id matching the input’s aria-controls. Given an accessible name (aria-label or aria-labelledby). Removed or hidden when collapsed. |
| Each suggestion | role="option" | A unique id (referenced by aria-activedescendant). aria-selected="true" on the chosen option. Contains the visible option text. |
| Result announcer | aria-live="polite" | A visually hidden region, separate from the listbox, updated with the result count ("8 results available") whenever filtering changes. |
The three aria-autocomplete values describe how typing relates to the list: "list" shows filtered suggestions in the popup (the common case), "both" also completes the input inline with the rest of the top match, and "none" means the list is not filtered by what you type. For how each attribute is exposed to assistive technology, see the ARIA roles & attributes reference.
3. The Heart of the Pattern: aria-activedescendant
Every other composite widget you have built moves focus. Tabs and menus use roving tabindex — real focus hops from element to element. A combobox cannot do that, because the user must keep typing while browsing options. So it uses the opposite technique.
Roving tabindex (tabs, menus)
Real DOM focus moves. The focused element changes with element.focus() and tabindex flips between 0 and -1. There is no text field to keep typing into.
Virtual focus (combobox)
Real focus stays on the input. aria-activedescendantpoints at the active option's id, and a CSS class shows the highlight. The screen reader announces that option; the caret never leaves the field.
Concretely: when the popup is open and the user presses Down, you set input.setAttribute("aria-activedescendant", optionId), add your .is-active class to that option, and scroll it into view. You do not call focus() on the option. The referenced option must be a real descendant of the listbox the input controls, and it must have a stable, unique id.
4. The Conformant HTML Markup
Here is an editable combobox with list autocomplete in its open state, with the first option active. Note the role="combobox" on the input, the paired aria-controls / id, and the separate aria-live region that is not inside the listbox.
<label id="fruit-label" for="fruit">Choose a fruit</label>
<input
id="fruit"
role="combobox"
aria-labelledby="fruit-label"
aria-controls="fruit-listbox"
aria-expanded="true"
aria-autocomplete="list"
aria-activedescendant="fruit-opt-0"
autocomplete="off"
type="text"
/>
<ul id="fruit-listbox" role="listbox" aria-label="Fruit">
<li id="fruit-opt-0" role="option" aria-selected="true">Apricot</li>
<li id="fruit-opt-1" role="option">Avocado</li>
<li id="fruit-opt-2" role="option">Banana</li>
</ul>
<!-- Separate polite live region for the result count -->
<div class="sr-only" aria-live="polite">3 results available</div>When the popup is closed, set aria-expanded="false", remove aria-activedescendant, and hide (or remove) the listbox. Setting autocomplete="off"on the input stops the browser's own autofill dropdown from colliding with yours.
5. The Keyboard Interaction Model
Because focus lives in a real text field, ordinary typing, the caret keys, and text selection must all keep working. The combobox keys layer on top of that — they never take over the input.
| Key | Action |
|---|---|
| Down Arrow | Opens the popup if closed and moves the active option to the first item; if already open, moves to the next option. Focus stays in the input — only aria-activedescendant changes. |
| Up Arrow | Opens the popup and moves to the last option, or moves to the previous option when open. Wraps or stops at the ends per your design. |
| Enter | Selects the active option: places its value in the input, closes the popup, and returns focus behavior to plain typing. |
| Escape | Closes the popup without changing the value. Pressing it again may clear the input, depending on the variant. |
| Alt + Down Arrow | Opens the popup without moving the active option — a way to reveal suggestions without committing to one. |
| Home / End | Moves the text caret to the start / end of the input value (an editable combobox is a real text field first). |
| Printable characters | Typed into the input as normal; your handler filters the options and updates the live-region result count. |
| Tab | Moves focus out of the combobox. Many implementations also select the active option on Tab; if so, do it predictably. |
A select-only combobox (no typing, like a styled <select>) uses the same keys, but printable characters do first-letter matching instead of filtering, and the control is a <button> or a non-editable element with role="combobox" rather than a text input.
6. Wiring It Up in Vanilla JavaScript
This handler shows the core mechanics: filter on input, open and close the popup, move the active option with aria-activedescendant, select on Enter, and announce the count. It is deliberately minimal so the moving parts are visible.
const input = document.getElementById("fruit")
const listbox = document.getElementById("fruit-listbox")
const status = document.querySelector("[aria-live]")
const ALL = ["Apricot", "Avocado", "Banana", "Cherry", "Date"]
let activeIndex = -1
let options = []
function open() {
input.setAttribute("aria-expanded", "true")
listbox.hidden = false
}
function close() {
input.setAttribute("aria-expanded", "false")
input.removeAttribute("aria-activedescendant")
listbox.hidden = true
activeIndex = -1
}
function render(matches) {
listbox.innerHTML = ""
options = matches.map((label, i) => {
const li = document.createElement("li")
li.id = "fruit-opt-" + i
li.role = "option"
li.textContent = label
li.addEventListener("click", () => select(i))
listbox.append(li)
return li
})
status.textContent = matches.length
? matches.length + " results available"
: "No results found"
}
function setActive(i) {
options.forEach((o) => o.classList.remove("is-active"))
activeIndex = (i + options.length) % options.length
const el = options[activeIndex]
el.classList.add("is-active")
el.setAttribute("aria-selected", "true")
input.setAttribute("aria-activedescendant", el.id) // virtual focus
el.scrollIntoView({ block: "nearest" })
}
function select(i) {
input.value = options[i].textContent
close()
}
input.addEventListener("input", () => {
const q = input.value.toLowerCase()
render(ALL.filter((x) => x.toLowerCase().includes(q)))
open()
})
input.addEventListener("keydown", (e) => {
const isOpen = input.getAttribute("aria-expanded") === "true"
if (e.key === "ArrowDown") { e.preventDefault(); isOpen ? setActive(activeIndex + 1) : open() }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(activeIndex - 1) }
else if (e.key === "Enter" && activeIndex > -1) { e.preventDefault(); select(activeIndex) }
else if (e.key === "Escape") { close() }
})Two details matter most: setActive() only ever changes aria-activedescendant and a class — it never calls focus() — and the result count is written into the polite live region on every filter, satisfying 4.1.3 Status Messages. Debounce that announcement for fast typists.
7. In React: Reach for a Headless Library
The combobox is the one pattern where hand-rolling in production is rarely worth it. The edge cases — async results, composition events for IME input, screen-reader quirks, virtualized long lists — are exactly what a mature library has already solved. Prefer a headless one so you keep full control of the markup and styling while inheriting correct semantics and keyboard handling.
// React Aria (Adobe) — correct ARIA combobox, your own styling
import { useComboBoxState } from "react-stately"
import { useComboBox, useFilter } from "react-aria"
function FruitCombobox(props) {
const { contains } = useFilter({ sensitivity: "base" })
const state = useComboBoxState({ ...props, defaultFilter: contains })
const inputRef = React.useRef(null)
const listBoxRef = React.useRef(null)
const popoverRef = React.useRef(null)
const { inputProps, listBoxProps, labelProps } = useComboBox(
{ ...props, inputRef, listBoxRef, popoverRef }, state,
)
// inputProps already includes role="combobox", aria-expanded,
// aria-controls, aria-activedescendant, and the keyboard handlers.
return (
<div>
<label {...labelProps}>{props.label}</label>
<input {...inputProps} ref={inputRef} />
{state.isOpen && <ListBox {...listBoxProps} state={state} listBoxRef={listBoxRef} />}
</div>
)
}Good options: Downshift, React Aria's useComboBox, Headless UI Combobox, and Radix. The React accessibility guide covers useId and live regions; the same libraries have equivalents for Vue and Angular.
How to Test an Accessible Combobox
Automated scanners confirm the roles exist and aria-controls resolves, but only a manual pass proves the focus model and announcements actually work. Run this on every combobox:
- Operate it with the keyboard only. Tab to the input, type to filter, arrow through options, Enter to select, Escape to dismiss — no mouse, no dead ends.
- Watch the caret. As you arrow through options, the text cursor must stay in the input. If focus jumps into the list, the
aria-activedescendantmodel is broken. - Listen with a screen reader. You should hear “combobox,” the expanded state, each active option as you arrow, and the result count when it changes. Verify with the screen reader testing guide.
- Filter to zero results. A “No results found” message is announced, and there is no stale active option left behind.
- Check the label. The combobox has a real, persistent accessible name — not just a placeholder.
Then layer automated checks on top with axe-core, and see automated vs manual testing for where each fits. Scan the live page with the URL accessibility auditor.
Common Combobox Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
| role="combobox" placed on a wrapping <div> around the input. | This is the obsolete ARIA 1.0 markup; modern screen readers do not announce it as an editable combobox (4.1.2). | Put role="combobox" directly on the focusable <input> element, per ARIA 1.2. |
| Moving real DOM focus into the listbox on Arrow Down. | The user can no longer type to filter, and the editable-combobox interaction breaks entirely (2.1.1). | Keep focus on the input; move a virtual highlight with aria-activedescendant instead. |
| No aria-expanded, or it never updates. | The screen reader cannot tell whether the popup is open, so the user does not know suggestions exist (4.1.2). | Toggle aria-expanded between true and false every time the popup opens and closes. |
| Suggestions are <div>s with click handlers, not role="option" in a listbox. | There is no list semantics or option count, and the keyboard cannot reach them (1.3.1, 2.1.1). | Use role="listbox" with role="option" children, each with a stable id. |
| The suggestion count is never announced. | A screen reader user hears nothing when results appear or disappear — the field feels silent (4.1.3). | Write "N results available" into a polite aria-live region on every filter change. |
| The input has only a placeholder, no real label. | The placeholder disappears on focus and is not a reliable accessible name (1.3.1, 4.1.2). | Associate a persistent <label> (or aria-labelledby) with the combobox input. |
Accessible Combobox Checklist
- Right pattern. There is a real, navigable popup of options — not just a search field. If
<datalist>fits, use it. - Role on the input.
role="combobox"is on the focusable input, not a wrapper, witharia-controlsto the listbox. - State is live.
aria-expandedflips true/false as the popup opens and closes. - Virtual focus. Arrow keys move
aria-activedescendant; real focus never leaves the input. - Options are options.
role="listbox"withrole="option"children, each with a stable id andaria-selectedon the chosen one. - Count announced. A polite
aria-liveregion reports results (4.1.3). - Real label. A persistent accessible name via
<label>oraria-labelledby.
Work through the full WCAG 2.2 checklist to see the combobox in the context of every other requirement.
Check Your Combobox on a Live Page
Scan any page with our free axe-core-powered auditor to catch a missing aria-expanded, a broken aria-controls target, or an input that is not a real combobox — then run the manual keyboard pass above.
Frequently Asked Questions
What is a combobox, and how is it different from a select or a search box?▾
A combobox is an input that is paired with a popup — almost always a listbox of options — so the user can either type to filter or pick from the list. It is the pattern behind autocomplete, typeahead, and autosuggest fields. It differs from a native select in that a select only lets you choose from a fixed list with no typing-to-filter and no custom option markup, and it differs from a plain search box in that a combobox exposes a structured set of suggestions the keyboard can move through, with the active suggestion announced. If your control is just a text field that submits a query, it is a search box, not a combobox — do not add combobox roles to it. Add them only when there is a real popup of options the user navigates.
Where does role="combobox" go — on the input or a wrapper div?▾
On the input itself. This is the single most important change between old and current ARIA. In ARIA 1.0 the combobox role went on a wrapper element that contained the input; that pattern is obsolete and has poor screen-reader support today. Since ARIA 1.1 and refined in 1.2, role="combobox" belongs on the focusable text input (or on a button for a select-only combobox). The input then carries aria-expanded, aria-controls pointing at the popup, and aria-autocomplete. If you are copying a snippet that wraps an input in <div role="combobox">, it is outdated — move the role onto the input.
What does aria-activedescendant do, and why not just move focus into the list?▾
aria-activedescendant is the mechanism that makes a combobox work. Real DOM focus stays on the input the whole time so the user can keep typing; aria-activedescendant on the input points at the id of the option that is currently "active" (visually highlighted). When the user presses the Down arrow, you do not move focus — you change which option id aria-activedescendant references and move a CSS highlight class to match. The screen reader announces that option as if it were focused, but the caret never leaves the input. Moving real focus into the listbox instead would stop the user from typing and would break the whole editable-combobox interaction, so the pattern deliberately uses a virtual focus via aria-activedescendant.
Can I use the native <datalist> element instead of building an ARIA combobox?▾
Sometimes, and you should consider it first. An <input> with a linked <datalist> gives you a browser-native autocomplete with zero JavaScript and reasonable keyboard and screen-reader support. It is a good fit for simple "suggest from a list of plain strings" cases like a country field. Its limits are real, though: you cannot style the dropdown or its options, options can only be plain text (no two-line results, icons, or grouping), the filtering and announcement behavior is inconsistent across browsers and screen readers, and you cannot control when the list opens. When you need styled results, rich option content, async suggestions, or predictable behavior across assistive technology, build the ARIA combobox — but reach for <datalist> when its constraints are acceptable.
How do I announce how many suggestions are available?▾
Use a visually hidden live region — an element with aria-live="polite" — separate from the listbox, and write a short message into it whenever the result count changes, such as "8 results available" or "No results found". Screen reader users cannot see the list appear, so without this announcement they have no idea whether typing produced any suggestions. Keep the message terse and debounce it so a fast typist is not flooded, and never put the count inside the listbox itself. This is what satisfies WCAG 4.1.3 Status Messages for the pattern, and it is the difference between an autocomplete that feels responsive to a screen reader user and one that feels silent and broken.
Should I really build a combobox from scratch, or use a library?▾
For production, use a well-tested headless library. The combobox is the most error-prone ARIA pattern — the interplay of aria-expanded, aria-activedescendant, focus management, filtering, and announcements has many edge cases, and browser and screen-reader quirks are significant. Downshift, React Aria's useComboBox, Headless UI Combobox, and Radix Combobox all implement the pattern correctly and stay updated as ARIA guidance evolves. Build one by hand to understand the mechanics — this guide shows you how — but ship a library so you inherit years of bug fixes for the cases that are hard to test yourself.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences