Clean Markup

Accessible Search and Autocomplete with the WAI-ARIA Combobox Pattern

Accessible Search and Autocomplete with the WAI-ARIA Combobox Pattern

Custom dropdowns and typeahead search inputs are among the most common yet frequently broken components on the modern web. Traditional bespoke implementations often hijack keyboard focus by physically shifting document.activeElement into floating menus, or worse, render non-semantic lists that screen readers cannot associate with the active text entry field. Early WAI-ARIA iterations exacerbated this confusion by requiring wrapper container nodes to hold the combobox role in ARIA 1.0, and then shifting structural requirements in ARIA 1.1, causing fractured assistive technology support across NVDA, JAWS, and VoiceOver.

The WAI-ARIA 1.2 Combobox pattern resolves these long-standing architectural headaches by standardizing a clean, unified mental model. The role="combobox" attribute is applied directly to the <input> element itself, while an adjacent or linked container acts as the suggestion list using role="listbox". By combining aria-autocomplete="list", aria-expanded, aria-controls, and aria-activedescendant, the text input retains real DOM focus continuously while managing virtual focus across the list of suggestions, guaranteeing uninterrupted typing and intuitive keyboard navigation.

Example

<div class="combobox-wrapper">
  <label for="country-search" id="combobox-label">Select a Country</label>
  <div class="input-container">
    <input
      id="country-search"
      type="text"
      role="combobox"
      aria-autocomplete="list"
      aria-expanded="false"
      aria-controls="country-listbox"
      aria-labelledby="combobox-label"
      aria-activedescendant=""
      autocomplete="off"
    />
    <button type="button" id="toggle-popup" tabindex="-1" aria-label="Toggle list"></button>
  </div>
  <ul
    id="country-listbox"
    role="listbox"
    aria-labelledby="combobox-label"
    hidden
  >
    <li id="opt-ca" role="option" aria-selected="false">Canada</li>
    <li id="opt-mx" role="option" aria-selected="false">Mexico</li>
    <li id="opt-us" role="option" aria-selected="false">United States</li>
  </ul>
</div>

Implementation

/**
 * ARIA 1.2 Compliant Combobox with Virtual Focus
 */
class AccessibleCombobox {
  constructor(inputEl, listboxEl, options = []) {
    this.input = inputEl;
    this.listbox = listboxEl;
    this.options = options;
    this.activeIndex = -1;
    this.isOpen = false;

    this.init();
  }

  init() {
    this.input.addEventListener('input', () => this.onInput());
    this.input.addEventListener('keydown', (e) => this.onKeyDown(e));
    this.input.addEventListener('blur', () => this.onBlur());
    this.renderOptions(this.options);
  }

  open() {
    if (this.isOpen) return;
    this.isOpen = true;
    this.listbox.hidden = false;
    this.input.setAttribute('aria-expanded', 'true');
  }

  close() {
    if (!this.isOpen) return;
    this.isOpen = false;
    this.listbox.hidden = true;
    this.input.setAttribute('aria-expanded', 'false');
    this.setActiveIndex(-1);
  }

  renderOptions(filteredOptions) {
    this.listbox.innerHTML = '';
    filteredOptions.forEach((optionText, index) => {
      const li = document.createElement('li');
      li.id = `combobox-option-${index}`;
      li.role = 'option';
      li.setAttribute('aria-selected', 'false');
      li.textContent = optionText;
      li.addEventListener('click', () => this.selectOption(index));
      this.listbox.appendChild(li);
    });
  }

  setActiveIndex(index) {
    const items = this.listbox.querySelectorAll('[role="option"]');
    if (this.activeIndex >= 0 && items[this.activeIndex]) {
      items[this.activeIndex].setAttribute('aria-selected', 'false');
      items[this.activeIndex].classList.remove('is-active');
    }

    this.activeIndex = index;

    if (index >= 0 && items[index]) {
      const activeItem = items[index];
      activeItem.setAttribute('aria-selected', 'true');
      activeItem.classList.add('is-active');
      this.input.setAttribute('aria-activedescendant', activeItem.id);
      activeItem.scrollIntoView({ block: 'nearest' });
    } else {
      this.input.removeAttribute('aria-activedescendant');
    }
  }

  selectOption(index) {
    const items = this.listbox.querySelectorAll('[role="option"]');
    if (items[index]) {
      this.input.value = items[index].textContent;
      this.close();
      this.input.focus();
    }
  }

  onInput() {
    const query = this.input.value.trim().toLowerCase();
    const filtered = this.options.filter((opt) => opt.toLowerCase().includes(query));
    this.renderOptions(filtered);
    this.open();
    this.setActiveIndex(-1);
  }

  onKeyDown(event) {
    const items = this.listbox.querySelectorAll('[role="option"]');
    const count = items.length;

    switch (event.key) {
      case 'ArrowDown':
        event.preventDefault();
        if (!this.isOpen) {
          this.open();
          this.setActiveIndex(0);
        } else if (count > 0) {
          const next = this.activeIndex + 1 >= count ? 0 : this.activeIndex + 1;
          this.setActiveIndex(next);
        }
        break;

      case 'ArrowUp':
        event.preventDefault();
        if (this.isOpen && count > 0) {
          const prev = this.activeIndex - 1 < 0 ? count - 1 : this.activeIndex - 1;
          this.setActiveIndex(prev);
        }
        break;

      case 'Enter':
        if (this.isOpen && this.activeIndex >= 0) {
          event.preventDefault();
          this.selectOption(this.activeIndex);
        }
        break;

      case 'Escape':
        if (this.isOpen) {
          event.preventDefault();
          this.close();
        }
        break;

      case 'Tab':
        this.close();
        break;
    }
  }

  onBlur() {
    // Delay closing slightly so item click handlers can trigger before removal
    setTimeout(() => this.close(), 150);
  }
}

// Initializing the combobox
const input = document.getElementById('country-search');
const listbox = document.getElementById('country-listbox');
const combobox = new AccessibleCombobox(input, listbox, [
  'Canada',
  'Denmark',
  'Germany',
  'Japan',
  'Mexico',
  'Norway',
  'United Kingdom',
  'United States'
]);

Main Point: The WAI-ARIA 1.2 Combobox pattern pairs role="combobox" on the input with aria-activedescendant virtual focus to deliver fully accessible autocomplete without disturbing physical DOM focus.

Credits

Photo by Luca Bravo on Unsplash

← Home