Forms & Input

Combobox

Available

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

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


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