# Autocomplete fields with silent, mouse-only suggestions

Source: https://easeweb.dev/learn/combobox
Topics: Custom inputs > Autocomplete suggestions are announced and can be picked with a keyboard (WCAG 2.1.1, 4.1.2, 4.1.3)
The fix: Either build the field as an ARIA combobox: role="combobox", aria-expanded and aria-controls on the input, a listbox of options, aria-activedescendant moved by the arrow keys while focus stays in the field, and the count line as a status region. Or, for a plain list of words, give the input a list attribute and a datalist, and let the browser handle the keys and announcements, knowing you can't style that list and some browser and screen reader pairs read it poorly.
Test with: keyboard, screen reader, zoom, forced colors
References: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/ https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/combobox_role https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/datalist

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A travel site's booking form has a Destination field. Type "por" and a list drops down under it: Porto, Portland, Portsmouth, with a line above it saying "3 suggestions". With a mouse, a click on Portland fills the field. With a keyboard, it depends on the build. In one version the arrow keys do nothing, and Tab leaves the field for the Search button, so the list can't be used at all. In another, the down arrow moves a blue highlight through the cities and Enter picks one, but a screen reader says nothing as it moves, or only reads back "por". Type "pxq" and the line changes to "No destinations found", and that is silent too.

## Why it fails

Success Criterion 2.1.1 asks that everything a mouse can do can be done with a keyboard. In the first version, the suggestions are `li` elements with a click handler: they can't take focus and no key reaches them. Success Criterion 4.1.2 asks that a control's role and state, and anything the user can change, are available to assistive technology. In the second version, the field is still announced as a plain text field, the list has no role, and the highlight is a CSS class: the keyboard works, but the browser has nothing to tell a screen reader about which suggestion is highlighted or that a list is open. The count line is a status message, which Success Criterion 4.1.3 asks to be announced without moving focus, and it is a plain `p`.

## Who is affected

Type-ahead fields often lead into the most important step of a form, such as a destination, an address or a product. On a phone, the list can open under the on-screen keyboard, so even sighted users may depend on the count line to know there is anything to pick.

## What automation and AI miss

A scan loads the page and finds a labelled text field. The list doesn't exist until someone types, so there is nothing for it to check, and a list that does exist is markup with no ARIA to be wrong. AI assistants asked for an autocomplete often write exactly the second version: keydown handlers and an `active` class, sometimes with `role="listbox"` added to the list but no `aria-activedescendant` on the field, which still leaves the highlight silent. Others move focus into the list on the down arrow, so typing another letter goes nowhere. Only typing, arrowing and listening shows whether the list works.

## Before: suggestions that only a click can pick

```html
<label for="dest">Destination</label>
<input id="dest" type="text" autocomplete="off">
<p id="dest-count"></p>
<ul id="dest-list" class="suggestions" hidden></ul>
```

```js
input.addEventListener('input', () => {
  const matches = findCities(input.value);
  list.innerHTML = matches.map((city) => `<li>${city}</li>`).join('');
  list.hidden = !matches.length;
  count.textContent = !input.value ? '' : matches.length
    ? `${matches.length} suggestions` : 'No destinations found';
});
list.addEventListener('click', (event) => {
  input.value = event.target.textContent;
  list.hidden = true;
});
```

The suggestions are list items with a click handler. They can't take focus, no key reaches them, and nothing tells a screen reader they appeared.

## Before: a highlight that only the eye can see

```js
let active = -1;
input.addEventListener('keydown', (event) => {
  const items = [...list.children];
  if (event.key === 'ArrowDown') active = Math.min(active + 1, items.length - 1);
  else if (event.key === 'ArrowUp') active = Math.max(active - 1, 0);
  else if (event.key === 'Enter' && active > -1) {
    input.value = items[active].textContent;
    list.hidden = true;
  } else return;
  event.preventDefault();
  items.forEach((li, i) => li.classList.toggle('active', i === active));
});
```

This is the same field with keys added. The arrows and Enter work, but the field has no combobox role, the list has no listbox role, and the highlight is a class, so a screen reader user hears nothing as it moves and can't tell which city Enter will pick.

## After: an ARIA combobox

```html
<label for="dest">Destination</label>
<input id="dest" type="text" role="combobox" autocomplete="off"
  aria-autocomplete="list" aria-expanded="false" aria-controls="dest-list">
<p id="dest-count" role="status"></p>
<ul id="dest-list" role="listbox" aria-label="Destinations" hidden></ul>
```

```js
let active = -1;
let timer;
function move(index) {
  active = index;
  [...list.children].forEach((li, i) => li.setAttribute('aria-selected', String(i === active)));
  if (active > -1) input.setAttribute('aria-activedescendant', `dest-${active}`);
  else input.removeAttribute('aria-activedescendant');
}
function show(matches) {
  list.innerHTML = matches
    .map((city, i) => `<li id="dest-${i}" role="option" aria-selected="false">${city}</li>`)
    .join('');
  list.hidden = !matches.length;
  input.setAttribute('aria-expanded', String(!list.hidden));
  move(-1);
}
function pick(li) {
  input.value = li.textContent;
  show([]);
}
input.addEventListener('input', () => {
  const matches = findCities(input.value);
  show(matches);
  // Wait for a pause in typing, so the count is said once, not on every letter.
  clearTimeout(timer);
  timer = setTimeout(() => {
    count.textContent = !input.value ? '' : matches.length
      ? `${matches.length} suggestions` : 'No destinations found';
  }, 500);
});
input.addEventListener('keydown', (event) => {
  const n = list.hidden ? 0 : list.children.length;
  if (event.key === 'ArrowDown' && n) move(active < n - 1 ? active + 1 : 0);
  else if (event.key === 'ArrowUp' && n) move(active > 0 ? active - 1 : n - 1);
  else if (event.key === 'Enter' && active > -1) pick(list.children[active]);
  else if (event.key === 'Escape' && n) show([]);
  else return;
  event.preventDefault();
});
// Keep focus in the field, so a click picks before the field's blur closes the list.
list.addEventListener('mousedown', (event) => event.preventDefault());
list.addEventListener('click', (event) => {
  const li = event.target.closest('[role="option"]');
  if (li) pick(li);
});
input.addEventListener('blur', () => show([]));
```

```css
/* The highlight comes from the state a screen reader reads, never from a class of its own. */
#dest-list [aria-selected="true"] {
  background: var(--highlight);
  outline: 2px solid transparent;
}
@media (forced-colors: active) {
  #dest-list [aria-selected="true"] {
    outline-color: Highlight;
  }
}
```

Focus never leaves the field, so typing another letter still works. The field is announced as a combo box that says whether its list is open, and `aria-activedescendant` points at the highlighted option, so each arrow press reads that city out. Enter and a click both fill the field, Escape and leaving the field close the list, and the count line is a status region that is in the page from the start, so "3 suggestions" and "No destinations found" are announced once typing pauses. The transparent outline turns into a real one in forced colors, where the background fill is dropped.

## After: a native datalist, for a plain list of words

```html
<label for="dest">Destination</label>
<input id="dest" type="text" list="dest-options">
<datalist id="dest-options">
  <option value="Lisbon"></option>
  <option value="Paris"></option>
  <option value="Porto"></option>
  <option value="Portland"></option>
  <option value="Portsmouth"></option>
  <option value="Prague"></option>
</datalist>
```

The browser draws the suggestions, filters them as the person types, moves through them with the arrow keys, fills the field on Enter, and exposes the field as a combo box with no script. To offer results from a server, replace the `option` elements as the person types, and the browser updates the list.

This passes only as far as the browser's own list does. Its text doesn't grow when the page is zoomed, it can't be styled, so you can't fix its look in forced colors, and some browser and screen reader pairs, including NVDA with Firefox, don't read the suggestions at all. The browser also decides how typed text matches, and shows no count or "no matches" line. Test it in every browser and screen reader you support before relying on it.

## Which option to use

Both pass 2.1.1 and 4.1.2. Use the ARIA combobox: it behaves the same in every browser, its list zooms and can be drawn for forced colors, it can show and announce a count, and the datalist's gaps are in the browser, where no code of yours can close them. The cost is the script above, which is yours to test and maintain. A datalist is a reasonable shortcut for a plain list of words on an internal tool whose browsers and screen readers you know, where the gaps above don't reach anyone who uses it.

## Implementation decisions

If the list is short and the answer must be one of its entries, such as a country, a native `select` may serve better than any combobox. An autocomplete suits a long list, or one where people may type something that isn't on it.

Don't move DOM focus into the list. With focus on an option, the next letter typed goes nowhere, and screen readers leave their editing mode. Keep focus in the field and move `aria-activedescendant`. Don't put `aria-live` on the list either: every city would be read on each keystroke.

The count line must be in the page before the first keystroke, and outside anything your framework re-renders with the list, or its first change is not announced. Some screen readers read nothing when the same words are written twice; if "3 suggestions" can follow "3 suggestions", clear the line before writing it again.

Some combobox components in UI libraries implement this pattern, and some ship the second Before with an accessible name. Check any library by typing, arrowing and listening, as in the steps below, before you trust its documentation. For other dropdown failures, see [search panels that close on Tab and drop keyboard focus](/learn/panel-dismissal) and [search and filter results that update without a word](/learn/search-results).

## Verify the fix

1. Inspect the field: it has role="combobox", aria-expanded and aria-controls naming a listbox of options, or a list attribute naming a datalist.
2. Type a few letters and press the down and up arrows: the highlight moves through the suggestions, focus stays in the field, and aria-activedescendant follows the highlight.
3. Press Enter on a suggestion, then try Escape, a mouse click and a tap: each picks or closes as expected and leaves focus in the field.
4. With a screen reader, type, arrow through the suggestions, and type something nothing matches: you hear that suggestions are there, each highlighted city, and "No destinations found".
5. At 200% zoom and in forced colors, check the suggestions stay readable and the highlighted one is visible.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the shared field or autocomplete component changes.

## Limits

This is a starting pattern for a list autocomplete, not for comboboxes that complete text inline, open a grid or a tree, or allow several choices. The snippets leave out fetching and matching. Test the complete journey, with your real data, in the browsers and assistive technology you support.
