Accessible address combobox with ARIA 1.2
Learn the combobox pattern for an address field (roles, aria-activedescendant, live region and keyboard), see how our script does it, and build a minimal version yourself.
An address field with suggestions is one of the most used controls in a checkout, and one of those that most often fails with a screen reader or keyboard. The user does not hear that suggestions have appeared, cannot reach them with the arrow keys, or loses focus when the list closes.
ARIA 1.2 describes a fixed pattern for exactly this kind of field: a combobox with a listbox. This guide walks through the pattern, shows how danadresse-autocomplete-1.0.0.js implements it, and gives you a minimal plain JavaScript version to build on.
The pattern
Focus stays in the text field the whole time. The list is a separate element the field points to. When the user moves through the list, you do not move focus. You tell the screen reader which suggestion is highlighted.
| Element | Role and attributes | Purpose |
|---|---|---|
input | role="combobox" | Says the field has an attached list |
input | aria-autocomplete="list" | Suggestions appear in a list. The field does not complete the text itself |
input | aria-expanded = true or false | Whether the list is open right now |
input | aria-controls="<list id>" | Which list the field controls |
input | aria-activedescendant="<id of highlighted option>" | Which suggestion is highlighted. Removed when nothing is |
ul | role="listbox" and aria-label | The list itself. May contain only options |
li | role="option", unique id, aria-selected | One suggestion. aria-selected="true" on the highlighted one |
div | role="status" (live region) | Announces the number of suggestions, "none found" and errors without moving focus |
The field also needs a visible label. A placeholder is not enough, because it disappears once the user starts typing.
Keyboard
| Key | List closed | List open |
|---|---|---|
| Down arrow | Opens the list if there is enough text | Highlights the next suggestion. From the last it wraps to the first |
| Up arrow | Nothing | Highlights the previous suggestion. From the first it wraps to the last |
| Enter | Submits the form as usual | Chooses the highlighted suggestion. With none highlighted the form is submitted |
| Escape | Nothing | Closes the list, the text stays |
| Tab | Moves focus as usual | Closes the list and moves focus on |
| Home / End | Moves the caret in the text | First / last suggestion, if a suggestion is already highlighted |
Only block Enter when a suggestion is actually highlighted. Otherwise the user cannot submit the form from the field.
How danadresse-autocomplete-1.0.0.js does it
The script from Address autocomplete widget in plain JavaScript follows the pattern above. These are the choices worth knowing if you build or test your own:
- The list holds only suggestions. Messages such as "No addresses found", the error text and the credit line sit in a separate
divbelow the list. Alistboxmay only haveoptionchildren; otherwise some screen readers announce the wrong count. - Live region. A hidden
divwithrole="status"andaria-live="polite"gets the text "8 suggestions", "No addresses found", the error message or the chosen address.politemeans the screen reader waits until the user pauses. - Highlight without moving focus. The arrow keys set
aria-selectedon a suggestion andaria-activedescendanton the field. The suggestion is scrolled into view withscrollIntoView({ block: "nearest" }). - Mouse and touch. Suggestions are chosen on
mousedownwithpreventDefault(). The field then keeps focus until the choice is registered. - Drill-down. When the user picks a street name, the script inserts the text, places the caret at
caretposand searches again. Focus stays in the field, so the screen reader continues in the same context. - Visible focus and size. The highlighted suggestion has a 2 px outline on top of the background colour, so the highlight is not shown by colour alone. Each row is at least 44 px tall.
- The credit link has
tabIndex = -1, so Tab does not land in the list. - Language. The texts exist in Danish, English and Ukrainian and are picked from the
langoption or<html lang>.
The older danadresse-widget.js follows the same pattern: role="combobox", aria-expanded, aria-controls, aria-activedescendant, a polite live region with the result count, and the credit line outside the listbox. It differs in four ways: Danish texts only, no Home/End, the arrow keys stop at the first and last suggestion, and the highlight is shown by background colour alone. Use 1.0.0 if you need those. The one-tag script danadresse-auto.min.js for webshops also uses the combobox pattern with a live region.
A minimal version of your own
If you do not want to use our script, here is a starting point of about 80 lines. First the markup and the CSS it needs:
<label for="adr">Address</label> <div class="adr"> <input id="adr" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="adr-liste" aria-describedby="adr-hjaelp" autocomplete="off" spellcheck="false"> <ul id="adr-liste" role="listbox" aria-label="Address suggestions" hidden></ul> </div> <p id="adr-hjaelp">Type street and house number, and choose with the arrow keys.</p> <div id="adr-status" role="status" aria-live="polite" class="sr-only"></div> <style> .adr { position: relative; } #adr-liste { position: absolute; left: 0; right: 0; margin: 0; padding: 4px; list-style: none; background: #fff; border: 1px solid #888; } #adr-liste li { min-height: 44px; display: flex; align-items: center; padding: 0 12px; cursor: pointer; } #adr-liste li[aria-selected="true"] { background: #e6f0ff; outline: 2px solid #0050b3; outline-offset: -2px; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } </style>
Then the script. It uses a domain-locked browser key, see Browser keys and allowed domains:
const API_KEY = "dawa_live_your_browser_key"; const input = document.getElementById("adr"); const liste = document.getElementById("adr-liste"); const status = document.getElementById("adr-status"); let forslag = []; let aktiv = -1; let timer; let ctrl; function aabn(aaben) { liste.hidden = !aaben; input.setAttribute("aria-expanded", String(aaben)); if (!aaben) marker(-1); } function marker(i) { aktiv = i; liste.querySelectorAll("li").forEach((li, n) => li.setAttribute("aria-selected", String(n === i))); if (i < 0) { input.removeAttribute("aria-activedescendant"); return; } const li = document.getElementById(`adr-opt-${i}`); input.setAttribute("aria-activedescendant", li.id); li.scrollIntoView({ block: "nearest" }); } function vis() { liste.replaceChildren(...forslag.map((f, i) => { const li = document.createElement("li"); li.id = `adr-opt-${i}`; li.setAttribute("role", "option"); li.setAttribute("aria-selected", "false"); li.textContent = f.forslagstekst; li.addEventListener("mousedown", (e) => { e.preventDefault(); vaelg(i); }); return li; })); aabn(forslag.length > 0); status.textContent = forslag.length ? `${forslag.length} suggestions` : "No addresses found"; } async function soeg(q) { ctrl?.abort(); ctrl = new AbortController(); const url = new URL("https://api.danadresse.dk/autocomplete"); url.searchParams.set("q", q); url.searchParams.set("type", "adresse"); url.searchParams.set("per_side", "8"); try { const res = await fetch(url, { headers: { "X-Api-Key": API_KEY }, signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); forslag = await res.json(); vis(); } catch (e) { if (e.name === "AbortError") return; forslag = []; aabn(false); status.textContent = "Address lookup is unavailable. Type the address manually."; } } function vaelg(i) { const f = forslag[i]; if (f.type !== "adresse") { input.value = f.tekst; // street or entrance: search on input.setSelectionRange(f.caretpos, f.caretpos); soeg(input.value); return; } input.value = f.forslagstekst; aabn(false); status.textContent = f.forslagstekst; input.dispatchEvent(new CustomEvent("adresse-valgt", { detail: f, bubbles: true })); } input.addEventListener("input", () => { clearTimeout(timer); if (input.value.trim().length < 2) { ctrl?.abort(); aabn(false); return; } timer = setTimeout(() => soeg(input.value), 200); }); input.addEventListener("keydown", (e) => { const aaben = !liste.hidden && forslag.length > 0; if (e.key === "ArrowDown" && aaben) { e.preventDefault(); marker((aktiv + 1) % forslag.length); } else if (e.key === "ArrowUp" && aaben) { e.preventDefault(); marker(aktiv <= 0 ? forslag.length - 1 : aktiv - 1); } else if (e.key === "Enter" && aaben && aktiv >= 0) { e.preventDefault(); vaelg(aktiv); } else if (e.key === "Escape" && aaben) { e.preventDefault(); aabn(false); } else if (e.key === "Tab") { aabn(false); } }); input.addEventListener("blur", () => aabn(false));
This version deliberately leaves out a few things our script has: texts in several languages, Home/End, reopening a closed list with the down arrow, and a destroy() function. Add them if you need them.
The response the live region counts
The live region announces the length of the response. This is how many suggestions the API returns for a typical input:
curl -s "https://api.danadresse.dk/autocomplete?q=R%C3%A5dhuspladsen&type=adresse&per_side=8" \ -H "X-Api-Key: $DANADRESSE_API_KEY"
Keep per_side low, for example 5 to 8. A long list is hard to take in with a screen reader, and the user can always type more to narrow it down.
How to test
- Keyboard only. Tab into the field, type, use the arrow keys, choose with Enter, close with Escape. Focus must never leave the field while you move through the list.
- Screen reader. Test with VoiceOver on macOS/iOS and NVDA on Windows. You should hear the number of suggestions, the highlighted suggestion when you press the down arrow, and the chosen address.
- Automated. Run axe or Lighthouse on the page with the list open. They catch, for example, a
listboxwith the wrong children or a missinglabel. - Errors. Use a wrong key and check that the error message is announced and the field still works as a plain text input.
Next steps
- Address autocomplete widget in plain JavaScript
- Address field in React — the same pattern as a component.
- Browser keys and allowed domains
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)