# 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.
>
> https://danadresse.dk/en/guides/tilgaengelig-combobox · verified 2026-11-02
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](/en/guides/vanilla-autocomplete-widget) 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 id=combobox-html run=no
<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](/en/guides/browser-noegler-domaener):

```js id=combobox-js run=syntax
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:

```bash id=count-request run=live expect="forslagstekst"
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

- [Address autocomplete widget in plain JavaScript](/en/guides/vanilla-autocomplete-widget)
- [Address field in React](/en/guides/react-autocomplete) — the same pattern as a component.
- [Browser keys and allowed domains](/en/guides/browser-noegler-domaener)
