// pattern

Accessible Combobox / Autocomplete Pattern

A text input with autocomplete filtering that opens a role="listbox" of suggestions. Uses role="combobox", aria-activedescendant for virtual focus, and a live region to announce result counts. No native HTML element supports this interaction — ARIA is required.

aria-required advanced wcag-2.2

// 01 · live demo

Live Demo

Type to filter countries. Use Arrow Down/Arrow Up to navigate options. Press Enter to select, Escape to close.

Inaccessible version (no ARIA, mouse-only):

Australia
Brazil
Canada
France
Germany
India
Japan
Mexico
South Korea
Spain
United Kingdom
United States

// 02 · the code

The Code

HTML
<!-- Combobox container -->
<div class="combobox">
  <label for="country-input">Choose a country</label>

  <input
    type="text"
    id="country-input"
    role="combobox"
    aria-expanded="false"
    aria-autocomplete="list"
    aria-controls="country-listbox"
    aria-activedescendant=""
    autocomplete="off"
  >

  <ul role="listbox" id="country-listbox" hidden>
    <li role="option" id="opt-1" aria-selected="false">
      Australia
    </li>
    <li role="option" id="opt-2" aria-selected="false">
      Brazil
    </li>
    <li role="option" id="opt-3" aria-selected="false">
      Canada
    </li>
    <!-- More options... -->
  </ul>

  <!-- Live region for result count -->
  <div class="sr-only" aria-live="polite"
       id="combo-status"></div>
</div>
CSS
.combobox {
  position: relative;
  max-width: 20rem;
}

.combobox label {
  display: block;
  font-weight: 600;
  margin-bottom: 0.25rem;
}

.combobox input[role="combobox"] {
  width: 100%;
  padding: 0.5rem 1rem;
  font-size: 0.875rem;
  border: 2px solid #d1d5db;
  border-radius: 0.25rem;
  background: #fff;
  /* SC 2.5.8: minimum 24x24 target */
  min-height: 2.75rem;
  box-sizing: border-box;
}

/* SC 2.4.13: focus indicator with 3:1 contrast */
.combobox input[role="combobox"]:focus-visible {
  outline: 2px solid #5b2a86;
  outline-offset: 2px;
  border-color: #5b2a86;
}

.combobox [role="listbox"] {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin: 0;
  padding: 0.25rem 0;
  list-style: none;
  background: #fff;
  border: 2px solid #d1d5db;
  border-radius: 0.25rem;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  max-height: 14rem;
  overflow-y: auto;
  z-index: 10;
}

.combobox [role="listbox"][hidden] {
  display: none;
}

.combobox [role="option"] {
  padding: 0.5rem 1rem;
  cursor: pointer;
  /* SC 2.5.8: minimum 24x24 target */
  min-height: 2.75rem;
  display: flex;
  align-items: center;
}

.combobox [role="option"]:hover {
  background: #f3f4f6;
}

/* Active option via aria-selected */
.combobox [role="option"][aria-selected="true"] {
  background: #5b2a86;
  color: #fff;
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .combobox input,
  .combobox [role="listbox"] {
    transition: none;
  }
}

/* Screen reader only — live region */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
JavaScript
function initCombobox(container) {
  const input = container.querySelector('[role="combobox"]');
  const listbox = container.querySelector('[role="listbox"]');
  const status = container.querySelector('[aria-live]');
  const allOptions = Array.from(
    listbox.querySelectorAll('[role="option"]')
  );
  let activeIndex = -1;

  function getVisibleOptions() {
    return allOptions.filter(
      opt => !opt.hidden
    );
  }

  function openListbox() {
    listbox.hidden = false;
    input.setAttribute('aria-expanded', 'true');
  }

  function closeListbox() {
    listbox.hidden = true;
    input.setAttribute('aria-expanded', 'false');
    input.setAttribute('aria-activedescendant', '');
    clearSelection();
    activeIndex = -1;
  }

  function clearSelection() {
    allOptions.forEach(opt => {
      opt.setAttribute('aria-selected', 'false');
    });
  }

  function setActiveOption(index) {
    const visible = getVisibleOptions();
    if (visible.length === 0) return;

    clearSelection();
    activeIndex = index;
    const option = visible[activeIndex];
    option.setAttribute('aria-selected', 'true');
    input.setAttribute(
      'aria-activedescendant', option.id
    );

    // Scroll option into view if needed
    option.scrollIntoView({ block: 'nearest' });
  }

  function selectOption(option) {
    input.value = option.textContent.trim();
    closeListbox();
  }

  function filterOptions() {
    const query = input.value.toLowerCase().trim();
    let matchCount = 0;

    allOptions.forEach(opt => {
      const text = opt.textContent.toLowerCase();
      const matches = text.includes(query);
      opt.hidden = !matches;
      if (matches) matchCount++;
    });

    activeIndex = -1;
    clearSelection();
    input.setAttribute('aria-activedescendant', '');

    if (query.length > 0 && matchCount > 0) {
      openListbox();
      // Announce count via live region
      status.textContent =
        matchCount + (matchCount === 1
          ? ' result available'
          : ' results available');
    } else if (query.length > 0) {
      openListbox();
      status.textContent = 'No results available';
    } else {
      closeListbox();
      status.textContent = '';
    }
  }

  // Filter on input
  input.addEventListener('input', filterOptions);

  // Keyboard navigation
  input.addEventListener('keydown', (e) => {
    const visible = getVisibleOptions();

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (listbox.hidden) {
          filterOptions();
          openListbox();
        }
        if (visible.length > 0) {
          const next = activeIndex < visible.length - 1
            ? activeIndex + 1 : 0;
          setActiveOption(next);
        }
        break;

      case 'ArrowUp':
        e.preventDefault();
        if (visible.length > 0) {
          const prev = activeIndex > 0
            ? activeIndex - 1
            : visible.length - 1;
          setActiveOption(prev);
        }
        break;

      case 'Home':
        if (listbox.hidden) return;
        e.preventDefault();
        if (visible.length > 0) {
          setActiveOption(0);
        }
        break;

      case 'End':
        if (listbox.hidden) return;
        e.preventDefault();
        if (visible.length > 0) {
          setActiveOption(visible.length - 1);
        }
        break;

      case 'Enter':
        e.preventDefault();
        if (activeIndex >= 0) {
          selectOption(visible[activeIndex]);
        }
        break;

      case 'Escape':
        if (!listbox.hidden) {
          e.preventDefault();
          closeListbox();
        }
        break;

      case 'Tab':
        if (activeIndex >= 0 && !listbox.hidden) {
          selectOption(visible[activeIndex]);
        } else {
          closeListbox();
        }
        // Don't prevent default — let Tab
        // move focus to the next field
        break;
    }
  });

  // Click to select an option
  listbox.addEventListener('click', (e) => {
    const option = e.target.closest('[role="option"]');
    if (option && !option.hidden) {
      selectOption(option);
      input.focus();
    }
  });

  // Close on outside click
  document.addEventListener('click', (e) => {
    if (!container.contains(e.target)) {
      closeListbox();
    }
  });
}

// Initialize all comboboxes on the page
document.querySelectorAll('.combobox')
  .forEach(initCombobox);
Why autocomplete="off" on the input? Browsers show their own autocomplete suggestions over the input, which conflicts with the custom listbox. Setting autocomplete="off" prevents the browser's native suggestions from overlapping your ARIA-powered listbox, avoiding confusion for both sighted and screen reader users.

// 03 · why these decisions

Why These Decisions

Why role="combobox"

There is no native HTML element that combines a text input with a filterable suggestions list. The <select> element doesn't support typing to filter, and <datalist> has severe cross-browser inconsistencies and no way to control styling or behavior. The combobox role tells screen readers that this input has an associated popup list of suggestions, enabling them to announce the correct interaction model.

Why aria-activedescendant instead of moving real focus

When a user arrows through options, real DOM focus stays in the input. Only the aria-activedescendant attribute changes to point to the active option's ID. This is essential because moving real focus to a list item would move the cursor out of the input — the user would lose their typing position and couldn't continue refining their search. Virtual focus via aria-activedescendant lets screen readers announce the active option while the user keeps typing.

Why aria-autocomplete="list"

This attribute tells screen readers that a list of suggestions will appear based on what the user types. Without it, screen readers announce a generic combobox with no hint that typing will produce filtered results. The "list" value specifically means the suggestions come from a predefined list (as opposed to "inline" which auto-completes in the text field itself, or "both" which does both).

Why aria-expanded on the input

The aria-expanded attribute on the combobox input communicates whether the listbox popup is currently visible. Screen readers announce "collapsed" or "expanded" so users know whether they can arrow through options. Without this attribute, users have no way to know if the dropdown is open or closed, leading to confusion when arrow keys either do nothing or start navigating unexpectedly.

Why aria-controls linking to the listbox

The aria-controls attribute on the input points to the listbox's id, establishing a programmatic relationship between the two elements. This tells assistive technology that operating the input controls the content of the listbox. Some screen readers use this relationship to let users jump directly to the controlled element.

Why announce the result count via a live region

Screen readers can't count how many options remain after filtering — they only see the option that aria-activedescendant currently points to. A live region with aria-live="polite" announces messages like "5 results available" after each filter operation. Without this, a screen reader user has no idea whether their search matched zero items, one item, or dozens, and must arrow through the entire list to find out.

// 04 · keyboard interaction

Keyboard Interaction

Key Action
Type Filters the listbox to show matching options. Opens the listbox if it was closed.
Arrow Down Opens the listbox if closed. Moves virtual focus to the next option. Wraps from last to first.
Arrow Up Moves virtual focus to the previous option. Wraps from first to last.
Enter Selects the currently highlighted option, fills the input, and closes the listbox
Escape Closes the listbox without selecting an option. Does not clear the input value.
Home Moves virtual focus to the first option in the listbox (when open)
End Moves virtual focus to the last option in the listbox (when open)
Tab Selects the current option (if one is highlighted), closes the listbox, and moves focus to the next form field
Virtual focus vs. real focus Unlike tabs where arrow keys move real DOM focus, the combobox keeps real focus in the input at all times. The aria-activedescendant attribute creates "virtual focus" — the screen reader announces the active option, but the keyboard cursor stays in the text field. This lets users continue typing to refine their search while simultaneously reviewing suggestions with arrow keys.

// 05 · wcag 2.2 success criteria

WCAG 2.2 Success Criteria

This pattern satisfies the following WCAG 2.2 success criteria:

  • 4.1.2 Name, Role, Value Level A — The input exposes role="combobox", the list exposes role="listbox" with role="option" children. States like aria-expanded and aria-selected are programmatically updated.
  • 2.1.1 Keyboard Level A — Full keyboard operation: typing to filter, arrow keys to navigate, Enter to select, Escape to close, Home/End for first/last option.
  • 2.1.2 No Keyboard Trap Level A — Tab and Escape both exit the widget. Users can always leave the combobox and continue navigating the page.
  • 1.3.1 Info and Relationships Level A — The <label> is associated with the input via for/id. aria-controls links the combobox to its listbox. aria-activedescendant establishes the virtual focus relationship.
  • 2.4.3 Focus Order Level A — Focus stays in the input throughout the interaction. Virtual focus via aria-activedescendant keeps the logical focus order intact without disrupting the page's tab sequence.
  • 4.1.3 Status Messages Level AA — The result count (e.g. "5 results available") is announced via an aria-live="polite" region without moving focus.
  • 2.4.13 Focus Appearance Level AAA — The input has a visible 2px focus outline. The active option is highlighted with a contrasting background color for clear visual indication.
  • 2.5.8 Target Size (Minimum) Level AA — Both the input and each option meet the 24x24px minimum target size via min-height: 2.75rem.
  • 3.3.2 Labels or Instructions Level A — A visible <label> element is associated with the input, providing a persistent text label that identifies the field's purpose.

// 06 · screen reader behavior

Screen Reader Behavior

When navigating to the combobox

  • NVDA: "Choose a country, combobox, autocomplete list, collapsed" — announces the label, role, autocomplete type, and expanded state
  • JAWS: "Choose a country edit combo, type text" — announces label and input type
  • VoiceOver: "Choose a country, combobox, has autocomplete" — announces label, role, and autocomplete capability

When typing to filter

  • NVDA: After typing "fra" — "3 results available" (from the live region) — announces the count without interrupting typing
  • JAWS: After filtering — announces result count from the live region, then individual options when arrowed to
  • VoiceOver: After typing — "3 results available" (from the live region) — politely announces after a brief pause

When arrowing through options

  • NVDA: Arrow Down — "France, 1 of 3" — announces option text and position via aria-activedescendant
  • JAWS: Arrow Down — "France 1 of 3" — announces option name and position
  • VoiceOver: Arrow Down — "France, text, 1 of 3" — announces the option content and position

When selecting an option

  • NVDA: Enter — "France, selected" — confirms the selection and closes the listbox
  • JAWS: Enter — "France" — fills the input with the selected value
  • VoiceOver: Enter — "France, collapsed" — confirms selection and announces the collapsed state

// 07 · common mistakes

Common Mistakes

Using a native <select> for autocomplete The <select> element doesn't support typing to filter options. It only allows selecting from a fixed list. For an autocomplete experience where users type to narrow results, you need a role="combobox" input paired with a role="listbox". Trying to hack a <select> into an autocomplete breaks both the visual and assistive technology experience.
Not using aria-activedescendant Moving real DOM focus to list options when arrowing through them pulls the cursor out of the input. The user loses their typing position and can't refine their search without tabbing back. aria-activedescendant creates virtual focus — the screen reader announces the active option while real focus stays in the input, preserving the cursor position.
Missing aria-expanded Without aria-expanded on the combobox input, screen readers can't tell users whether the suggestion list is visible. Users won't know if arrow keys will navigate options or do nothing. Always toggle aria-expanded between "true" and "false" when showing and hiding the listbox.
No result count announcement After filtering, screen reader users have no way to know how many options matched their input. Without a live region announcing the count (e.g., "5 results available"), users must arrow through the entire list to discover what's there. Add an aria-live="polite" region that updates with the match count on each filter.
Not handling Escape properly Pressing Escape should close the listbox without clearing the input value. Some implementations clear the input on Escape, which is destructive and unexpected. Others don't close the listbox at all, leaving users trapped. Escape should close the dropdown and leave the typed text intact so users can try a different interaction.
Using role="listbox" without role="option" children A listbox requires role="option" on each child item. Using plain <div> or <li> elements without the option role means screen readers see the listbox as empty — there are no selectable items. Every child of the listbox that the user can select must have role="option" and a unique id for aria-activedescendant to reference.

// 08 · native html vs. aria

Native HTML vs. ARIA

HTML provides <datalist> for basic autocomplete, but it has severe limitations. This table compares what native HTML offers versus what ARIA is required to build a fully accessible combobox.

Feature Native HTML ARIA Requirement
Input element <input> — provides text entry role="combobox" + aria-autocomplete="list"
Suggestions list <datalist> — very limited styling and behavior role="listbox" + role="option" children
Filtering None with <datalist> — browser controls filtering JavaScript filter logic required
Open/closed state None — browser manages <datalist> visibility aria-expanded="true/false" on the input
Active option None — no programmatic way to track active suggestion aria-activedescendant pointing to option id
Selection state None aria-selected="true" on the active option
Input-to-list link list attribute for <datalist> aria-controls pointing to listbox id
Label <label for> — works natively Same — native <label> works fine with combobox role
Result count None aria-live region for count announcements
The verdict The combobox pattern is almost entirely ARIA-driven. While <datalist> exists as a native option, it offers no control over styling, filtering behavior, keyboard navigation, or screen reader announcements. For any production autocomplete that needs consistent behavior across browsers and assistive technologies, role="combobox" with a custom listbox is the only viable approach. The <label> element remains the one part where native HTML does the job perfectly.