Forms & Input

Combobox

Available

A text input combined with a suggestion list (autocomplete). One of the most complex patterns in the APG.

Specification updated

  • Enhancement#3311(opens in a new tab)Keyboard Interface Practice: Add note on focus management for pointer activation

What Is a Combobox?

A combobox is a UI that combines a text input with a popup list of suggestions that appears as the user types. It is commonly used for search autocomplete, address and state lookups, tagging, and similar interactions.

It is essentially a fusion of a free-text input and a selectable listbox. Because of this, communicating state (whether the popup is open, which option is highlighted) is complex, making it one of the most challenging patterns in the APG.

Why Does Accessibility Matter?

This is achieved through role="combobox" + aria-expanded +aria-controls on the input, role="listbox" on the popup, andaria-activedescendant to communicate the highlighted option.

Live Demo (Recommended Implementation)

A combobox for searching US states. As you type, suggestions are filtered. Try operating it without a mouse — use the arrow keys, Enter, and Esc.

Accessible Combobox (Autocomplete)

Try it: type to see suggestions → ↓ to enter the list → ↑ ↓ to navigate → Enter to confirm → Esc to dismiss. Try filtering with 'Cal', 'New', 'Al', etc.

Tip

Focus stays on the input at all times. Instead of moving focus,aria-activedescendant tells assistive technology which option is currently highlighted. This lets users keep typing while selecting from the list. The open/close state is conveyed through aria-expanded and the result count viaaria-live.

Keyboard Interaction

KeyActionPriority
Text inputFilter suggestions and open the popupRequired
Open the popup / move highlight to the next suggestionRequired
Move highlight to the previous suggestionRequired
EnterAccept the highlighted suggestion and closeRequired
EscClose the popupRequired
Esc (when popup is closed)Clear the inputOptional
Home / EndMove the cursor within the input field (standard text editing)Optional

Required WAI-ARIA Roles and Properties

TargetAttribute / RoleMeaning
Input fieldrole="combobox"Indicates a composite widget combining text input with a popup.
Input fieldaria-expanded="true | false"Indicates whether the popup is open. Must be updated on every open/close.
Input fieldaria-controls="listbox-id"Identifies which popup (listbox) this input controls.
Input fieldaria-activedescendant="option-id"Points to the currently highlighted option. Updated on each move; removed when closed.
Input fieldaria-autocomplete="list"Indicates that suggestions are provided via a list based on input.
Popuprole="listbox" + nameContainer for the suggestions. Named via aria-labelledby or similar.
Each suggestionrole="option" + unique id + aria-selectedA single suggestion. aria-selected="true" when highlighted.
Live regionaria-live="polite"Announces changes such as the number of available suggestions (optional but recommended).

Implementation: Recommended Pattern (Good)

Good / Recommended

Give the input role="combobox" and manage aria-expanded/aria-controls/aria-activedescendant. The popup uses role="listbox" > role="option".

Markup:

<label id="cmb-label" for="cmb-input">Search states</label>

<input
  id="cmb-input"
  type="text"
  role="combobox"
  aria-expanded="false"
  aria-controls="cmb-list"
  aria-autocomplete="list"
  autocomplete="off"
>

<ul id="cmb-list" role="listbox" aria-labelledby="cmb-label" hidden>
  <li role="option" id="cmb-opt-0">Alabama</li>
  <li role="option" id="cmb-opt-1">Alaska</li>
  <!-- ... -->
</ul>

<!-- Live region for announcing the number of results -->
<p class="sr-only" aria-live="polite"></p>

Script for filtering, open/close, highlighting, and selection:

const input = document.getElementById('cmb-input');
const listbox = document.getElementById('cmb-list');
const options = [...listbox.querySelectorAll('[role="option"]')];
let activeIndex = -1;

const visible = () => options.filter((o) => !o.hidden);

const open = () => {
  input.setAttribute('aria-expanded', 'true');
  listbox.hidden = false;
};
const close = () => {
  input.setAttribute('aria-expanded', 'false');
  listbox.hidden = true;
  activeIndex = -1;
  input.removeAttribute('aria-activedescendant');
  options.forEach((o) => o.setAttribute('aria-selected', 'false'));
};

// Communicate the currently highlighted option via aria-activedescendant
const setActive = (i) => {
  const vis = visible();
  options.forEach((o) => o.setAttribute('aria-selected', 'false'));
  if (i < 0 || i >= vis.length) {
    activeIndex = -1;
    input.removeAttribute('aria-activedescendant');
    return;
  }
  activeIndex = i;
  vis[i].setAttribute('aria-selected', 'true');
  input.setAttribute('aria-activedescendant', vis[i].id);
  vis[i].scrollIntoView({ block: 'nearest' });
};

// Filter options based on input and announce the count
const filter = () => {
  const q = input.value.trim();
  options.forEach((o) => {
    o.hidden = q !== '' && !o.textContent.includes(q);
  });
  return visible().length;
};

input.addEventListener('input', () => {
  filter() > 0 ? open() : close();
  setActive(-1);
});

input.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
    e.preventDefault();
    if (input.getAttribute('aria-expanded') !== 'true' && filter() > 0) open();
    const n = visible().length;
    if (!n) return;
    setActive(e.key === 'ArrowDown'
      ? (activeIndex + 1) % n
      : (activeIndex - 1 + n) % n);
  } else if (e.key === 'Enter' && activeIndex >= 0) {
    e.preventDefault();
    input.value = visible()[activeIndex].textContent;
    close();
  } else if (e.key === 'Escape') {
    input.getAttribute('aria-expanded') === 'true' ? close() : (input.value = '');
  }
});

options.forEach((opt) => opt.addEventListener('click', () => {
  input.value = opt.textContent;
  close();
  input.focus();
}));

Note

The key principle is "never move focus away from the input." Option highlighting is handled virtually via aria-activedescendant, and state is conveyed through aria-expanded plus the result count announced viaaria-live. This is how typing and selection work together seamlessly.

Anti-pattern (Bad)

Below is a plain <input> with <div> suggestions.You can click options with a mouse, but keyboard users cannot navigate to them, and screen readers are unaware of the suggestions, count, or selection.

Broken combobox built with input + div

Try it: typing shows suggestions, but pressing ↓ doesn't move to them, and Enter doesn't select anything. Screen readers have no idea suggestions have appeared.

<!-- ❌ Anti-pattern: plain input + div suggestions -->
<input type="text" placeholder="Enter a state">

<div class="suggestions">
  <!-- No roles, no ARIA. Can't select options with keyboard -->
  <div class="item" onclick="pick(this)">California</div>
  <div class="item" onclick="pick(this)">New York</div>
</div>

Bad / Avoid

Problems with this implementation:

  • Can't select options via keyboard — suggestions are div elements with no arrow key or focus support.
  • No roles or state — missing role="combobox"/aria-expanded/aria-controls, so the open/close state and relationship are not communicated.
  • No highlight indication — missing aria-activedescendant/aria-selected, so there is no way to know which option is current.
  • No result count — missing aria-live, so "X results available" is never announced.

Implementation Checklist

Field Notes — Where practice diverges from the APG

aria-selected marks committed selection, not the moving highlight

The APG says
In the APG combobox examples, aria-selected="true" moves together with aria-activedescendant: each arrow-key highlight also marks that option as selected.
What practice does
React Aria and Base UI apply aria-selected only to the committed value. The keyboard highlight is conveyed by aria-activedescendant (and visual styling) alone; aria-selected="true" appears only once the user commits, e.g. with Enter.
Why
Focus ("the option I am pointing at") and selection ("the value I committed") are different concepts. Moving aria-selected with the highlight makes screen readers announce "selected" on every arrow key, misleading users into thinking a value was committed — and in multi-select UIs the meaning of "selected" becomes ambiguous. Since aria-activedescendant already conveys focus, the field practice reserves aria-selected for actual selection state.

Adopted by:React AriaBase UI

Sources:React Aria — useComboBox(opens in a new tab)Base UI — Combobox(opens in a new tab)

On mobile, drop role="combobox" for a role="searchbox" inside a tray

The APG says
The input has role="combobox" tied to the listbox via aria-expanded / aria-controls, with arrow keys as the primary way to move between options.
What practice does
React Aria's ComboBox replaces the outer input with a button on mobile; tapping opens a full-height tray containing a search input — with role="searchbox", not combobox.
Why
Device testing showed that keeping role="combobox" on the tray input made screen readers announce "double tap to close" incorrectly. Touch screen reader users also have no physical arrow keys — the APG's primary navigation model simply does not exist for them. Combined with the state-management complexity of duplicated inputs, the team designed mobile as a different UI. A canonical case of prioritizing per-input-method usability over pattern fidelity.

Adopted by:React AriaReact Spectrum

Sources:React Aria Blog — Creating an accessible autocomplete experience(opens in a new tab)

The listbox cannot live next to the input — portals plus dynamic aria-hidden fill the gap

The APG says
APG examples place the listbox as a DOM sibling of the combobox, assuming screen readers can move naturally between input and list.
What practice does
React Aria portals the listbox to the end of the document to escape overflow clipping. To restore the broken assumption it walks the DOM with a TreeWalker, applies aria-hidden to everything except the input and listbox, and keeps watching mutations with a MutationObserver while open.
Why
Real apps put comboboxes inside scroll containers where an adjacent listbox gets clipped. Portalling fixes the visual problem but breaks reading order by putting DOM distance between input and list. React Aria's answer — temporarily hiding everything else from assistive technology — recreates the screen reader experience the APG structure was designed to produce, by other means. Porting the intended experience, not the textbook DOM.

Adopted by:React AriaReact Spectrum

Sources:React Aria Blog — Creating an accessible autocomplete experience(opens in a new tab)


Source (English):Combobox Pattern — W3C APG(opens in a new tab)