Accessibility pattern · Form inputs

Combobox with autocomplete

Typing filters a list of cities below the field. Focus never leaves the field: the arrow keys move a highlight through the list, and aria-activedescendant tells screen readers which city it is on. Nothing is chosen until Enter or a click, so typing and arrowing never change the value behind people's backs. A polite status says how many cities match once typing pauses.

WCAG criteria
7
Keyboard rules
7
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Check delivery

We deliver to 24 cities across India.

Type a few letters, then choose from the list.

Choose a city to see when an order would arrive.

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
A–ZTyping filters the cities and opens the list; the number that match is announced when typing pauses.
Arrow Down or Arrow UpOpens the list, or moves the highlight to the next or previous city, wrapping at the ends.
AltArrow DownOpens the list without moving the highlight.
EnterChooses the highlighted city, puts it in the field and closes the list.
EscapeCloses the list; pressed again with the list closed, clears the field.
AltArrow UpCloses the list and keeps what is typed.
Arrow Left or Arrow RightMoves the text cursor and takes the highlight off the list, so typing goes on where it was.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Tab reaches the fieldCity, combo box, collapsed, edit text. Type a few letters, then choose from the list.
Typing "na", then a pauseExpanded. 5 results
Arrow DownChennai Tamil Nadu, 1 of 5
Enter chooses itChennai. Delivering to Chennai, Tamil Nadu. Usually arrives tomorrow
Typing a name with no matchCollapsed. No cities match

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<div class="ap-combobox" data-ap-combobox>
  <h3 class="ap-combobox__name">Check delivery</h3>
  <p class="ap-combobox__lead">We deliver to 24 cities across India.</p>

  <div class="ap-combobox__field">
    <div class="ap-combobox__row">
      <label class="ap-combobox__label" for="combobox-input">City</label>
      <p class="ap-combobox__count" role="status"></p>
    </div>
    <p class="ap-combobox__hint" id="combobox-hint">Type a few letters, then choose from the list.</p>
    <div class="ap-combobox__box">
      <svg class="ap-combobox__search" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg>
      <input class="ap-combobox__input" id="combobox-input" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="combobox-list" aria-describedby="combobox-hint" autocomplete="off" autocapitalize="off" spellcheck="false" />
      <button type="button" class="ap-combobox__clear" aria-label="Clear city" hidden>
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 7l10 10M17 7 7 17"/></svg>
      </button>
      <button type="button" class="ap-combobox__toggle" tabindex="-1" aria-label="Cities" aria-expanded="false" aria-controls="combobox-list">
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
      </button>
    </div>
    <div class="ap-combobox__popup" hidden>
      <ul class="ap-combobox__list" id="combobox-list" role="listbox" aria-label="Cities">
          <li class="ap-combobox__opt" role="option" id="combobox-o-ahmedabad" aria-selected="false" data-en="Ahmedabad" data-days="2"><span class="ap-combobox__city">Ahmedabad</span> <span class="ap-combobox__state">Gujarat</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-amritsar" aria-selected="false" data-en="Amritsar" data-days="3"><span class="ap-combobox__city">Amritsar</span> <span class="ap-combobox__state">Punjab</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-bengaluru" aria-selected="false" data-en="Bengaluru" data-days="1"><span class="ap-combobox__city">Bengaluru</span> <span class="ap-combobox__state">Karnataka</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-bhopal" aria-selected="false" data-en="Bhopal" data-days="3"><span class="ap-combobox__city">Bhopal</span> <span class="ap-combobox__state">Madhya Pradesh</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-bhubaneswar" aria-selected="false" data-en="Bhubaneswar" data-days="3"><span class="ap-combobox__city">Bhubaneswar</span> <span class="ap-combobox__state">Odisha</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-chennai" aria-selected="false" data-en="Chennai" data-days="1"><span class="ap-combobox__city">Chennai</span> <span class="ap-combobox__state">Tamil Nadu</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-coimbatore" aria-selected="false" data-en="Coimbatore" data-days="2"><span class="ap-combobox__city">Coimbatore</span> <span class="ap-combobox__state">Tamil Nadu</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-dehradun" aria-selected="false" data-en="Dehradun" data-days="3"><span class="ap-combobox__city">Dehradun</span> <span class="ap-combobox__state">Uttarakhand</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-guwahati" aria-selected="false" data-en="Guwahati" data-days="4"><span class="ap-combobox__city">Guwahati</span> <span class="ap-combobox__state">Assam</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-hyderabad" aria-selected="false" data-en="Hyderabad" data-days="1"><span class="ap-combobox__city">Hyderabad</span> <span class="ap-combobox__state">Telangana</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-indore" aria-selected="false" data-en="Indore" data-days="2"><span class="ap-combobox__city">Indore</span> <span class="ap-combobox__state">Madhya Pradesh</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-jaipur" aria-selected="false" data-en="Jaipur" data-days="2"><span class="ap-combobox__city">Jaipur</span> <span class="ap-combobox__state">Rajasthan</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-kochi" aria-selected="false" data-en="Kochi" data-days="2"><span class="ap-combobox__city">Kochi</span> <span class="ap-combobox__state">Kerala</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-kolkata" aria-selected="false" data-en="Kolkata" data-days="2"><span class="ap-combobox__city">Kolkata</span> <span class="ap-combobox__state">West Bengal</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-lucknow" aria-selected="false" data-en="Lucknow" data-days="2"><span class="ap-combobox__city">Lucknow</span> <span class="ap-combobox__state">Uttar Pradesh</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-mumbai" aria-selected="false" data-en="Mumbai" data-days="1"><span class="ap-combobox__city">Mumbai</span> <span class="ap-combobox__state">Maharashtra</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-mysuru" aria-selected="false" data-en="Mysuru" data-days="2"><span class="ap-combobox__city">Mysuru</span> <span class="ap-combobox__state">Karnataka</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-nagpur" aria-selected="false" data-en="Nagpur" data-days="2"><span class="ap-combobox__city">Nagpur</span> <span class="ap-combobox__state">Maharashtra</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-new-delhi" aria-selected="false" data-en="New Delhi" data-days="1"><span class="ap-combobox__city">New Delhi</span> <span class="ap-combobox__state">Delhi</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-patna" aria-selected="false" data-en="Patna" data-days="3"><span class="ap-combobox__city">Patna</span> <span class="ap-combobox__state">Bihar</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-pune" aria-selected="false" data-en="Pune" data-days="1"><span class="ap-combobox__city">Pune</span> <span class="ap-combobox__state">Maharashtra</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-surat" aria-selected="false" data-en="Surat" data-days="2"><span class="ap-combobox__city">Surat</span> <span class="ap-combobox__state">Gujarat</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-varanasi" aria-selected="false" data-en="Varanasi" data-days="3"><span class="ap-combobox__city">Varanasi</span> <span class="ap-combobox__state">Uttar Pradesh</span></li>
          <li class="ap-combobox__opt" role="option" id="combobox-o-visakhapatnam" aria-selected="false" data-en="Visakhapatnam" data-days="3"><span class="ap-combobox__city">Visakhapatnam</span> <span class="ap-combobox__state">Andhra Pradesh</span></li>
      </ul>
      <div class="ap-combobox__empty" hidden>
        <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11Z"/><path d="m10 8 4 4M14 8l-4 4"/></svg>
        <p class="ap-combobox__empty-lead">No cities match</p>
        <p class="ap-combobox__empty-hint">We may not deliver there yet. Check the spelling, or try a city nearby.</p>
      </div>
    </div>
  </div>

  <div class="ap-combobox__result">
    <p class="ap-combobox__prompt" data-ap-prompt>Choose a city to see when an order would arrive.</p>
    <div aria-live="polite">
      <dl class="ap-combobox__facts" data-ap-facts hidden>
        <div><dt>Delivering to</dt><dd data-ap-to></dd></div>
        <div><dt>Usually arrives</dt><dd data-ap-eta></dd></div>
      </dl>
    </div>
  </div>
</div>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.3.1 Info and Relationships Level A

    The field has a label and a hint, the list is a labelled listbox, and aria-controls ties the two together.

  • 1.4.1 Use of Color Level A

    The highlighted city has a ring and a bar, and the matching letters are underlined as well as tinted, so neither rests on color alone.

  • 2.1.1 Keyboard Level A

    Opening, filtering, moving, choosing, closing and clearing all work from the keyboard; the chevron button is a pointer extra.

  • 2.4.7 Focus Visible Level AA

    The field shows a focus ring, and the highlighted city has a ring of its own while focus stays in the field.

  • 3.2.2 On Input Level A

    Typing and arrowing never choose a city; only Enter or a click does, so nothing changes until people decide.

  • 4.1.2 Name, Role, Value Level A

    role=combobox with aria-expanded, aria-autocomplete="list" and aria-activedescendant exposes the field, its list and the city in focus.

  • 4.1.3 Status Messages Level AA

    The number of matches, or "No cities match", is a polite status, written once typing pauses.

Usage

When to use it

Use it

  • Choosing from a long list people know the answer to, like cities, countries or colleagues.
  • A search field that suggests completions people can take or ignore.

Use something else

  • Lists of fewer than about fifteen options: a native select is simpler and works with no script.
  • A choice people need to compare or browse: show the options as radio buttons or a listbox.
  • Free text with no suggestions: a plain text field is enough.

Common failures

How it usually goes wrong

  • Moving focus into the list

    If focus jumps to the options, typing stops working and people lose their place in the field. Focus stays in the input; aria-activedescendant points at the option.

  • Choosing on arrow

    Filling the field as the highlight moves changes the value without asking. Here the field changes only on Enter or a click.

  • A count announced on every key press

    "24 results, 9 results, 5 results" while someone types is noise. The count waits until typing pauses.

  • An empty list with no message

    A list that just disappears leaves people wondering whether it broke. The empty state says nothing matched, on screen and in the status.

  • Escape that does nothing

    People expect Escape to close the list, and again to clear the field. Both work here.

  • A clear button nobody can name

    An × icon with no text is announced as "button". The clear button here is named Clear city.

Notes

Building it

  • The matching letters are marked with the CSS Custom Highlight API, which styles a range of text without changing the DOM; browsers without it still filter, only without the tint.
  • Filtering compares the visible text as well as an English name in data-en, so the list keeps working when the page is translated.
  • The chevron button has tabindex=-1: keyboard users open the list with the arrow keys, and an extra tab stop would only slow them down.
  • Options keep focus in the field because the popup cancels mousedown; without that, a click on an option would blur the field and close the list first.
  • The delivery result sits in a polite live region that is always in the page, so a chosen city is announced with its delivery time, and the prompt that replaces it while typing is not.

Sources: WAI-ARIA Authoring Practices: Combobox · WAI-ARIA Authoring Practices: Combobox with list autocomplete

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction