Decide what the input accepts
An autocomplete combobox combines a text input with a list of suggested values. The example below lets people enter any text; choosing a suggestion simply replaces that text. This distinction matters: if your form requires a value from a fixed set, validate that requirement separately rather than assuming the presence of suggestions enforces it.
The example uses a fixed array of city names and runs in a modern browser with JavaScript enabled. It has no dependencies. To reproduce it, start with an empty HTML page, place the JavaScript in a script element at the end of the body, and load the page. The script creates its own label, input, and suggestion list. The short data set keeps the interaction easy to inspect; an application can replace the data source without changing the basic keyboard rules.
Connect the input and suggestion list
Keep keyboard focus on the text input while navigating suggestions. Give the input the combobox role, point aria-controls at the listbox, and update aria-expanded whenever the list opens or closes. Each suggestion needs the option role and a stable ID for as long as it is displayed. aria-activedescendant on the input identifies the option currently reached with the arrow keys; it does not move browser focus.
The label provides the input’s name. The list is hidden when it has no visible suggestions, and the input does not advertise an active option while the list is closed. In the script below, every new query rebuilds the short list, so the active index resets before new option IDs are assigned.
Implement filtering and selection
Use this complete script on the empty page. Arrow Down and Arrow Up cycle through visible options. Enter accepts the active option, Escape closes the list without replacing the typed text, and Tab follows normal browser focus behavior. A pointer can also select an option. The list’s pointerdown handler selects before the input loses focus; its click handler also covers activation that arrives as a click.
const cities = ['Amsterdam', 'Athens', 'Berlin', 'Boston', 'Lisbon', 'London', 'Oslo', 'Paris']; const label = document.createElement('label'); label.htmlFor = 'city'; label.textContent = 'City'; const input = document.createElement('input'); input.id = 'city'; input.autocomplete = 'off'; input.setAttribute('role', 'combobox'); input.setAttribute('aria-autocomplete', 'list'); input.setAttribute('aria-controls', 'city-options'); input.setAttribute('aria-expanded', 'false'); const list = document.createElement('ul'); list.id = 'city-options'; list.setAttribute('role', 'listbox'); list.hidden = true; document.body.append(label, input, list); let matches = []; let active = -1; function close() { list.hidden = true; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); active = -1; } function highlight(index) { active = index; [...list.children].forEach((option, i) => { option.setAttribute('aria-selected', String(i === active)); }); input.setAttribute('aria-activedescendant', `city-option-${active}`); list.children[active].scrollIntoView({ block: 'nearest' }); } function render() { const query = input.value.trim().toLocaleLowerCase(); matches = cities.filter(city => city.toLocaleLowerCase().includes(query)); list.replaceChildren(); matches.forEach((city, index) => { const option = document.createElement('li'); option.id = `city-option-${index}`; option.setAttribute('role', 'option'); option.setAttribute('aria-selected', 'false'); option.textContent = city; list.append(option); }); active = -1; input.removeAttribute('aria-activedescendant'); list.hidden = matches.length === 0; input.setAttribute('aria-expanded', String(!list.hidden)); } function choose(index) { input.value = matches[index]; close(); input.focus(); } input.addEventListener('input', render); input.addEventListener('focus', render); input.addEventListener('blur', close); input.addEventListener('keydown', event => { if (event.key === 'Escape' && !list.hidden) { event.preventDefault(); close(); } else if (event.key === 'Enter' && !list.hidden && active >= 0) { event.preventDefault(); choose(active); } else if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && matches.length) { event.preventDefault(); if (list.hidden) render(); const next = event.key === 'ArrowDown' ? (active + 1) % matches.length : (active - 1 + matches.length) % matches.length; highlight(next); } }); list.addEventListener('pointerdown', event => { const option = event.target.closest('[role="option"]'); if (!option) return; event.preventDefault(); choose([...list.children].indexOf(option)); }); list.addEventListener('click', event => { const option = event.target.closest('[role="option"]'); if (option && !list.hidden) choose([...list.children].indexOf(option)); });
Check the interaction, not just the filtering
Try typing “lo” to narrow the list, then use Arrow Down and Enter to choose a result. Type again to confirm that an earlier selection does not remain active. Test Arrow Up from an unselected list, Escape after highlighting an option, Tab out of the input, a pointer selection, and a query with no matches. The example shows suggestions when an empty input receives focus; change the filter condition if your interface should wait for a character.
Finally, test with a keyboard and a screen reader in the browsers you support. Confirm that the label is announced, the expanded state changes, and arrow navigation identifies the active option. ARIA attributes describe the interaction but cannot make a broken interaction accessible by themselves. If suggestions later come from a server, guard against older responses replacing newer results, and keep the same focus and selection behavior while results load.



