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.

6 min Level: Advanced ✓ Code tested

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.

ElementRole and attributesPurpose
inputrole="combobox"Says the field has an attached list
inputaria-autocomplete="list"Suggestions appear in a list. The field does not complete the text itself
inputaria-expanded = true or falseWhether the list is open right now
inputaria-controls="<list id>"Which list the field controls
inputaria-activedescendant="<id of highlighted option>"Which suggestion is highlighted. Removed when nothing is
ulrole="listbox" and aria-labelThe list itself. May contain only options
lirole="option", unique id, aria-selectedOne suggestion. aria-selected="true" on the highlighted one
divrole="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

KeyList closedList open
Down arrowOpens the list if there is enough textHighlights the next suggestion. From the last it wraps to the first
Up arrowNothingHighlights the previous suggestion. From the first it wraps to the last
EnterSubmits the form as usualChooses the highlighted suggestion. With none highlighted the form is submitted
EscapeNothingCloses the list, the text stays
TabMoves focus as usualCloses the list and moves focus on
Home / EndMoves the caret in the textFirst / 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 div below the list. A listbox may only have option children; otherwise some screen readers announce the wrong count.
  • Live region. A hidden div with role="status" and aria-live="polite" gets the text "8 suggestions", "No addresses found", the error message or the chosen address. polite means the screen reader waits until the user pauses.
  • Highlight without moving focus. The arrow keys set aria-selected on a suggestion and aria-activedescendant on the field. The suggestion is scrolled into view with scrollIntoView({ block: "nearest" }).
  • Mouse and touch. Suggestions are chosen on mousedown with preventDefault(). 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 caretpos and 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 lang option 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:

HTML#combobox-html
<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:

JavaScript#combobox-js
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:

Shell#count-request
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

  1. 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.
  2. 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.
  3. Automated. Run axe or Lighthouse on the page with the list open. They catch, for example, a listbox with the wrong children or a missing label.
  4. 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

Related guides

Markdown source Spotted an error? Tell us API-reference (Swagger)

Try it with a free key

2,000 calls/month on the free plan. No credit card.

Create account →