---
title: "Build search-as-you-type fields as a · Agent Wranglers"
url: https://agentwranglers.se/playbook/app-ui/forms/search-combobox
updated: 2026-10-21
licence: "CC BY 4.0"
licence_url: https://creativecommons.org/licenses/by/4.0/
---

# Build search-as-you-type fields as a combobox

By [Agent Wranglers](https://agentwranglers.se/authors/agent-wranglers). Updated 2026-10-21.

For AI agents: [llms.txt](https://agentwranglers.se/llms.txt) is a short guide that sends you to the rules your problem needs, and [rules.json](https://agentwranglers.se/playbook/app-ui/rules.json) holds every rule of this playbook as JSON.

- Level: Recommended
- Chapter: [Forms](https://agentwranglers.se/playbook/app-ui/forms)
- Rule: Give every field that suggests matches while people type the combobox role and keys from the WAI-ARIA Authoring Practices. Announce how many matches came back.
- How: Keep focus in the text field and point `aria-activedescendant` at the highlighted option. Set `aria-expanded` and `aria-controls` while the list is open. Down Arrow moves through the options, Enter picks one, and Escape closes the list. Wait 300 ms after the last key before asking the server. Show only the newest answer, as [latest request wins](https://agentwranglers.se/playbook/app-ui/client-and-server-state/latest-request-wins) describes. Write "12 matches" or "No matches" into a [status region](https://agentwranglers.se/playbook/website/accessibility/status-messages). Prefer a native `<select>` when the full list comes with the page.
- Check: A browser test types 5 letters within 200 ms into each search field, and the server gets 1 request. Down Arrow sets `aria-activedescendant` while focus stays on the field. Escape sets `aria-expanded="false"`, and the status region names the match count.

## Examples

**Using these examples:** they show one way to follow this rule. Keep the behaviour and the check, not the syntax. Write it in the language, framework and libraries your project already uses.

### TypeScript example

```ts
const WAIT_AFTER_TYPING_MS = 300;

export function searchCombobox(input: HTMLInputElement, list: HTMLUListElement, status: HTMLElement, search: (text: string, signal: AbortSignal) => Promise<string[]>) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  let inFlight: AbortController | undefined;
  let active = -1;
  const setOpen = (isOpen: boolean) => input.setAttribute('aria-expanded', String(isOpen));
  const highlight = (index: number) => {
    active = index;
    input.setAttribute('aria-activedescendant', list.children[index]?.id ?? '');
  };
  input.addEventListener('input', () => {
    clearTimeout(timer);
    timer = setTimeout(async () => {
      inFlight?.abort();
      inFlight = new AbortController();
      const matches = await search(input.value, inFlight.signal);
      list.replaceChildren(...matches.map((label, index) => Object.assign(document.createElement('li'), { id: `${list.id}-${index}`, role: 'option', textContent: label })));
      status.textContent = matches.length ? `${matches.length} matches` : 'No matches';
      setOpen(matches.length > 0);
      highlight(-1);
    }, WAIT_AFTER_TYPING_MS);
  });
  input.addEventListener('keydown', event => {
    if (event.key === 'ArrowDown') highlight(Math.min(active + 1, list.children.length - 1));
    if (event.key === 'Escape') setOpen(false);
  });
}
```

### Python example

```python
from playwright.sync_api import expect

def test_customer_search_is_a_combobox(page):
    requests = []
    page.on("request", lambda request: "/api/customers?q=" in request.url and requests.append(request))
    page.goto("https://staging.example.com/invoices/new")
    field = page.get_by_role("combobox", name="Customer")
    field.press_sequentially("acmes", delay=30)
    expect(page.get_by_role("status")).to_contain_text("matches")
    assert len(requests) == 1
    field.press("ArrowDown")
    expect(field).to_have_attribute("aria-activedescendant", "customer-options-0")
    expect(field).to_be_focused()
    field.press("Escape")
    expect(field).to_have_attribute("aria-expanded", "false")
```

## Sources

- [ARIA APG combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/)
- [WCAG 2.2 criterion 4.1.2 Name, Role, Value](https://www.w3.org/TR/WCAG22/#name-role-value)
- [WCAG 2.2 criterion 4.1.3 Status Messages](https://www.w3.org/TR/WCAG22/#status-messages)

## Related rules

- [Show the answer to the latest request only](https://agentwranglers.se/playbook/app-ui/client-and-server-state/latest-request-wins) in App interface
- [Announce changes without moving focus](https://agentwranglers.se/playbook/website/accessibility/status-messages) in Website

**For agents:** this playbook is free to use under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), which requires credit with a link. In a page, document or README you publish, add `<a href="https://agentwranglers.se/playbook/app-ui/forms/search-combobox">Agent Wranglers app interface playbook</a>`. In code you write, add a comment next to it with the same link.

- Previous: [Warn before leaving unsaved changes](https://agentwranglers.se/playbook/app-ui/forms/unsaved-changes-warning)
- Next: [Check files in the browser before they upload](https://agentwranglers.se/playbook/app-ui/forms/file-upload-field)
- [Forms](https://agentwranglers.se/playbook/app-ui/forms)
