Component Pattern Guide

Accessible Combobox & Autocomplete Guide

Updated July 2026Reviewed by Khushwant Parihar, CPACC

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 a menu, or a standalone listbox — where focus moves onto the options themselves — a combobox's focus must never leave the input, because 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

WCAG 2.2 success criteria that a correctly built combobox or autocomplete satisfies and what each requires
CriterionLevelWhat the combobox must do
1.3.1 Info & RelationshipsAThe input is a combobox, the popup is a listbox, and options are options — all set programmatically.
2.1.1 KeyboardAOpen, filter, move through options, select, and dismiss — all from the keyboard alone.
2.4.3 Focus OrderAFocus stays in the input; the popup does not trap or scatter focus.
2.4.7 Focus VisibleAAThe input shows a focus ring, and the active option has a clearly visible highlight.
4.1.2 Name, Role, ValueAThe combobox exposes its name, role, expanded state, and active-option value.
4.1.3 Status MessagesAAThe 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.

The elements in an ARIA combobox and the roles and attributes each needs
ElementRoleKey attributes
The text inputrole="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 popuprole="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 suggestionrole="option"A unique id (referenced by aria-activedescendant). aria-selected="true" on the chosen option. Contains the visible option text.
Result announceraria-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.

Keyboard keys and the action each performs in the combobox pattern
KeyAction
Down ArrowOpens 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 ArrowOpens 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.
EnterSelects the active option: places its value in the input, closes the popup, and returns focus behavior to plain typing.
EscapeCloses the popup without changing the value. Pressing it again may clear the input, depending on the variant.
Alt + Down ArrowOpens the popup without moving the active option — a way to reveal suggestions without committing to one.
Home / EndMoves the text caret to the start / end of the input value (an editable combobox is a real text field first).
Printable charactersTyped into the input as normal; your handler filters the options and updates the live-region result count.
TabMoves 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:

  1. 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.
  2. Watch the caret. As you arrow through options, the text cursor must stay in the input. If focus jumps into the list, the aria-activedescendant model is broken.
  3. 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.
  4. Filter to zero results. A “No results found” message is announced, and there is no stale active option left behind.
  5. 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

Common accessible-combobox anti-patterns, why they fail, and the fix
Anti-patternWhy it failsThe 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

  1. Right pattern. There is a real, navigable popup of options — not just a search field. If <datalist> fits, use it.
  2. Role on the input. role="combobox" is on the focusable input, not a wrapper, with aria-controls to the listbox.
  3. State is live. aria-expanded flips true/false as the popup opens and closes.
  4. Virtual focus. Arrow keys move aria-activedescendant; real focus never leaves the input.
  5. Options are options. role="listbox" with role="option" children, each with a stable id and aria-selected on the chosen one.
  6. Count announced. A polite aria-live region reports results (4.1.3).
  7. Real label. A persistent accessible name via <label> or aria-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

Top Pick

Accessible Listbox Guide

Build an accessible listbox: the role=listbox / option / group structure, single-select where selection follows focus, multi-select with aria-multiselectable and Space to toggle, aria-selected, the roving-tabindex vs aria-activedescendant focus choice, grouped and disabled options, type-ahead, and React, mapped to WCAG 2.2, plus when a group of checkboxes or a native select is the better choice
accessible listbox
role listbox
role option
+5 more
View guide
Top Pick

Accessible Tabs Guide

Build tabs with the WAI-ARIA Tabs pattern: tablist, tab, and tabpanel roles, aria-selected and aria-controls, roving tabindex, arrow-key navigation, automatic vs manual activation, and vertical tabs, with HTML, JavaScript, and React mapped to WCAG 2.2
accessible tabs
aria tabs
tabs pattern
+4 more
View guide
Top Pick

Accessible Accordion & Disclosure Guide

Build accessible accordions and disclosure widgets: the aria-expanded state, button-in-heading structure, aria-controls, the native details and summary element, single vs multi-expand, and the keyboard model, with HTML, JavaScript, and React mapped to WCAG 2.2
accessible accordion
aria-expanded
accordion aria
+3 more
View guide
Top Pick

WCAG 4.1.3 Status Messages Guide

Complete guide to announcing dynamic changes to screen readers without moving focus: ARIA live regions, role=status vs role=alert, toasts, form errors, result counts, code examples, and testing
status messages
wcag 4.1.3
role status
+1 more
View guide
Top Pick

Complete Keyboard Accessibility Guide

Definitive guide to keyboard accessibility with interactive demos, code examples, and testing checklists
focus management
keyboard navigation
wcag 2.1.1
View guide
Top Pick

WCAG 4.1.2 Name, Role, Value Guide

Complete guide to name, role, state, and value for UI components: accessible name computation, native HTML vs ARIA, code examples, common failures, and testing
wcag 4.1.2
aria role
screen reader
View guide