A combobox is a text input with a popup list of suggestions: as you type, the list offers values that match, and you can choose one or keep what you typed. A search box that suggests searches works this way, and so does the To field of an email app. HTML's <datalist> gives an input simple suggestions, but the browser decides how they look and which ones match, so most comboboxes are built from an <input> and a list. ARIA (Accessible Rich Internet Applications) attributes then tell assistive technology, such as screen readers, what each part is and what state it's in.
The WAI-ARIA Authoring Practices, the W3C's guide to accessible widgets, describe the pattern and its keyboard behaviour. These are its parts when the popup is a list:
| Part | Markup | What it's for |
|---|---|---|
| The input | An <input> with role="combobox", named by a <label> | Tells assistive technology that this field has a popup of values to choose from. It's the only part in the Tab order. |
| The popup | role="listbox" and an id | A list of options to choose one from, hidden until there's something to show. |
| Each suggestion | role="option" and an id, inside the listbox | One choice. Its text is its name, which is what gets read out. |
aria-controls | On the input, holding the listbox's id | Ties the input to its popup. |
aria-expanded | On the input: "true" while the popup shows, "false" while it's hidden | Says whether the list is open. |
aria-autocomplete | On the input, such as "list" | Says how the suggestions relate to what you type. |
aria-activedescendant | On the input, holding the active option's id | Says which option you've moved to. |
aria-selected="true" | On the active option | Marks it as selected: in a combobox, selection follows the active option. |
The guide describes four kinds of autocomplete, which differ in what you experience. ARIA defines one more value for aria-autocomplete, inline, for a completion that appears in the input with no list; the guide's combobox doesn't use it.
| Kind | aria-autocomplete | What you experience |
|---|---|---|
| No autocomplete | none | The list holds the same suggestions whatever you type, such as your recent searches. |
| List, with manual selection | list | The list shows suggestions that match what you've typed. Nothing is chosen for you: what you typed stays the value unless you choose a suggestion yourself. |
| List, with automatic selection | list | The same list, but the first suggestion is selected as soon as it appears. It becomes the value when you leave the field, unless you choose another or change the text. |
| List with inline autocomplete | both | Automatic selection, and the rest of the selected suggestion appears in the input after the caret, highlighted: type lo with London as the first suggestion, and the field shows lo followed by a highlighted ndon. |
While you move through the suggestions with the arrow keys, you may want to carry on typing, so focus, the element your key presses go to, stays on the input the whole time. The option you've moved to is the active option, and the input's aria-activedescendant holds its id. Assistive technology treats the active option as focused and reads it out, though document.activeElement is still the input, and when the attribute changes to another id, it reacts as if focus had moved. Here's a City field with a list and manual selection, suggesting Lisbon, London, and Lyon, whose options have the ids city-lisbon, city-london, and city-lyon:
| You | Text in the input | The list | aria-activedescendant |
|---|---|---|---|
| Tab to the field | Empty | Hidden, and aria-expanded is "false" | Not set |
Type l | l | Shows Lisbon, London, and Lyon, and aria-expanded is "true" | Not set: nothing is active yet |
| ArrowDown | l | Lisbon is active, with aria-selected="true" | "city-lisbon" |
| ArrowDown | l | London is active instead | "city-london" |
Type o | lo | Shows London only, and nothing is active | Not set: the letter went into the text |
| ArrowDown | lo | London is active | "city-london" |
| Enter | London | Hidden, and aria-expanded is "false" | Not set |
On every row, document.activeElement is the input, which is why the o went into the text while London was active. The other way to move through a widget's items is to move real focus, calling focus() on each item as the arrow keys reach it, with a roving tabindex keeping the group a single Tab stop. That suits tabs, toolbars, and menus, where the items themselves take your key presses. It doesn't suit a combobox: focusing an option would take the caret out of the input, and your next letter would go to the option instead of into the text.
A few rules come with aria-activedescendant. It holds an id, so every option needs one, unique in the page, and the element it names has to exist: in a combobox, inside the listbox that aria-controls names. When no option is active, remove the attribute rather than leave an old id in it. And the browser shows nothing: the option doesn't really have focus, so it gets no focus ring, :focus styles don't apply, and it isn't scrolled into view. Highlighting the active option and scrolling it into view are up to you.
The guide covers the other keys too. Escape closes the list, and when the list is already closed, it can clear the field; Up Arrow can open the list on its last option; Alt+Down Arrow can open it without making anything active. You meet comboboxes in search boxes, address fields, and tag pickers, and component libraries such as Downshift, React Aria, and Headless UI build them following this pattern.
The page has a text input with role="combobox" and an empty, hidden listbox for its suggestions:
<label for="fruit">Fruit</label>
<input id="fruit" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="fruit-options" autocomplete="off">
<ul id="fruit-options" role="listbox" aria-label="Fruits" hidden></ul>
Write setupCombobox(input, listbox, items), where items is an array of strings. Each time the user types (an input event), fill listbox with one <li role="option"> per item that starts with the input's text, ignoring case, in the order of items. Empty text matches every item. Give each option a unique id, and insert its text as text.
Open the list when at least one item matches, and close it when none does. Open means listbox.hidden is false and the input's aria-expanded is "true". Closed means hidden is true and aria-expanded is "false".
Focus stays on the input the whole time. The option the user has moved to is the active option: the input's aria-activedescendant holds its id, and it has aria-selected="true", which no other option has. With no active option, the input has no aria-activedescendant at all. Typing leaves no option active, and so does closing the list.
ArrowDown makes the next option active, or the first one if none is, and wraps from the last to the first. ArrowUp goes the other way, starting from the last. If the list is closed, both open it first, with the items that match the current text. Call preventDefault() for both, so the caret doesn't jump.
Enter with an active option puts that option's text in the input, closes the list, and calls preventDefault(). With no active option, Enter only closes the list. Clicking an option does what Enter does with it, and leaves focus on the input.
Escape closes the list if it's open, keeping the text, and clears the input if the list is already closed. ArrowLeft, ArrowRight, Home, and End move the caret as usual, so don't call preventDefault() for them; they leave the list open or closed as it was, with no option active. Handle the keys on keydown, reading event.key.
setupCombobox(
document.getElementById("fruit"),
document.getElementById("fruit-options"),
["Apple", "Apricot", "Banana", "Blueberry", "Cherry"],
);
// type "ap": the list opens with Apple and Apricot, and no option is active
// ArrowDown: Apple is active; ArrowDown: Apricot; ArrowDown: Apple again
// Enter: the input reads "Apple", and the list closes
const input = document.getElementById("fruit");
const listbox = document.getElementById("fruit-options");
const type = (text) => {
input.value = text;
fire(input, "input");
};
setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
type("aP");
expect([...listbox.children].map((option) => option.textContent)).toEqual(["Apple", "Apricot"]);
expect([...listbox.children].map((option) => option.tagName + " " + option.getAttribute("role"))).toEqual(["LI option", "LI option"]);
expect(listbox.hidden).toBe(false);
expect(input.getAttribute("aria-expanded")).toBe("true");
const input = document.getElementById("fruit");
const listbox = document.getElementById("fruit-options");
const type = (text) => {
input.value = text;
fire(input, "input");
};
setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
type("b");
expect(listbox.hidden).toBe(false);
type("bx");
expect(listbox.hidden).toBe(true);
expect(input.getAttribute("aria-expanded")).toBe("false");
type("bl");
expect([...listbox.children].map((option) => option.textContent)).toEqual(["Blueberry"]);
expect(listbox.hidden).toBe(false);
const input = document.getElementById("fruit");
const listbox = document.getElementById("fruit-options");
const fruits = ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"];
const press = (key) => fire(input, "keydown", { key });
const active = () => {
const id = input.getAttribute("aria-activedescendant");
return id === null ? null : (document.getElementById(id)?.textContent ?? "no element with id " + id);
};
const selected = () => [...listbox.querySelectorAll('[aria-selected="true"]')].map((option) => option.textContent);
setupCombobox(input, listbox, fruits);
input.focus();
expect(press("ArrowDown").defaultPrevented).toBe(true);
expect(listbox.hidden).toBe(false);
expect(input.getAttribute("aria-expanded")).toBe("true");
expect([...listbox.children].map((option) => option.textContent)).toEqual(fruits);
expect(active()).toBe("Apple");
expect(selected()).toEqual(["Apple"]);
press("ArrowDown");
expect(active()).toBe("Apricot");
expect(selected()).toEqual(["Apricot"]);
for (let step = 0; step < 4; step++) press("ArrowDown");
expect(active()).toBe("Grape");
press("ArrowDown");
expect(active()).toBe("Apple");
expect(document.activeElement).toBe(input);
const input = document.getElementById("fruit");
const listbox = document.getElementById("fruit-options");
const press = (key) => fire(input, "keydown", { key });
const active = () => {
const id = input.getAttribute("aria-activedescendant");
return id === null ? null : (document.getElementById(id)?.textContent ?? "no element with id " + id);
};
setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
input.focus();
input.value = "b";
expect(press("ArrowUp").defaultPrevented).toBe(true);
expect([...listbox.children].map((option) => option.textContent)).toEqual(["Banana", "Blueberry"]);
expect(input.getAttribute("aria-expanded")).toBe("true");
expect(active()).toBe("Blueberry");
press("ArrowUp");
expect(active()).toBe("Banana");
press("ArrowUp");
expect(active()).toBe("Blueberry");
expect([...listbox.querySelectorAll('[aria-selected="true"]')].map((option) => option.textContent)).toEqual(["Blueberry"]);
expect(document.activeElement).toBe(input);
const input = document.getElementById("fruit");
const listbox = document.getElementById("fruit-options");
const type = (text) => {
input.value = text;
fire(input, "input");
};
const press = (key) => fire(input, "keydown", { key });
const active = () => {
const id = input.getAttribute("aria-activedescendant");
return id === null ? null : (document.getElementById(id)?.textContent ?? "no element with id " + id);
};
const selected = () => [...listbox.querySelectorAll('[aria-selected="true"]')].map((option) => option.textContent);
setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
type("b");
expect(active()).toBeNull();
press("ArrowDown");
expect(active()).toBe("Banana");
type("bl");
expect(active()).toBeNull();
expect(selected()).toEqual([]);
press("ArrowDown");
expect(active()).toBe("Blueberry");
expect(press("ArrowLeft").defaultPrevented).toBe(false);
expect(active()).toBeNull();
expect(selected()).toEqual([]);
expect(listbox.hidden).toBe(false);
press("ArrowDown");
expect(press("End").defaultPrevented).toBe(false);
expect(active()).toBeNull();
const input = document.getElementById("fruit");
const listbox = document.getElementById("fruit-options");
const type = (text) => {
input.value = text;
fire(input, "input");
};
const press = (key) => fire(input, "keydown", { key });
setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
type("b");
press("ArrowDown");
press("ArrowDown");
expect(press("Enter").defaultPrevented).toBe(true);
expect(input.value).toBe("Blueberry");
expect(listbox.hidden).toBe(true);
expect(input.getAttribute("aria-expanded")).toBe("false");
expect(input.hasAttribute("aria-activedescendant")).toBe(false);
type("ch");
press("Enter");
expect(input.value).toBe("ch");
expect(listbox.hidden).toBe(true);
expect(input.getAttribute("aria-expanded")).toBe("false");
const input = document.getElementById("fruit");
const listbox = document.getElementById("fruit-options");
const type = (text) => {
input.value = text;
fire(input, "input");
};
const press = (key) => fire(input, "keydown", { key });
setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
type("ch");
press("ArrowDown");
press("Escape");
expect(listbox.hidden).toBe(true);
expect(input.getAttribute("aria-expanded")).toBe("false");
expect(input.hasAttribute("aria-activedescendant")).toBe(false);
expect(input.value).toBe("ch");
press("Escape");
expect(input.value).toBe("");
const input = document.getElementById("fruit");
const listbox = document.getElementById("fruit-options");
setupCombobox(input, listbox, ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"]);
input.focus();
input.value = "a";
fire(input, "input");
fire(listbox.children[1], "click");
expect(input.value).toBe("Apricot");
expect(listbox.hidden).toBe(true);
expect(input.getAttribute("aria-expanded")).toBe("false");
expect(document.activeElement).toBe(input);
const input = document.getElementById("fruit");
const listbox = document.getElementById("fruit-options");
setupCombobox(input, listbox, ["<img src=x onerror=alert(1)>", "<b>Bold</b>"]);
input.value = "<";
fire(input, "input");
expect(listbox.querySelector("img, b")).toBeNull();
expect([...listbox.children].map((option) => option.textContent)).toEqual(["<img src=x onerror=alert(1)>", "<b>Bold</b>"]);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Autocomplete combobox. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.