← Blog
7 Sept 2026combobox with autocompleteweb accessibilityARIA roleskeyboard navigationDOM Studio

How to Build an Accessible Combobox with Autocomplete

Accessible combobox with autocomplete using HTML, ARIA, keyboard, performance optimisations, and vanilla, DOM Studio, Vue examples.

How to Build an Accessible Combobox with Autocomplete

You’ve typed a few characters into an address field, a list has appeared, and the mouse user can already see the right suggestion. A keyboard-only user may be facing a different experience entirely: focus disappears, the list opens without an announcement, and pressing ArrowDown moves the browser cursor instead of selecting an option. The form hasn’t merely become inconvenient. It has become unpredictable.

A combobox with autocomplete combines text entry, filtering, and selection. That combination is useful, but it creates responsibilities that a styled input and an unordered list can’t meet on their own. The control must expose its state, communicate changes to assistive technology, preserve a logical focus model, and remain usable when suggestions arrive asynchronously or the layout is enlarged.

This guide approaches the problem as a production front-end task. It compares a vanilla custom element, a headless DOM Studio primitive, and a Vue wrapper, while keeping the underlying accessibility pattern consistent across all three.

Table of Contents

Introduction to Accessible Combobox

A plain input with a visually attractive dropdown often passes a quick visual review. Type into it, click a result, and the interaction appears complete. The problems surface when someone presses Tab, uses a screen reader, zooms the page, or receives no matching result from the server.

A keyboard user should be able to reach the field, understand that suggestions are available, move through them, and commit a choice without leaving the input unexpectedly. If the list opens but the user receives no indication that it expanded, the interface has hidden a change in state. If the active suggestion is only shown through colour, the user has no reliable way to identify the current option.

The distinction between a finite combobox and a dynamic autocomplete also matters. A stable list of departments may suit a control with an explicit popup button. A remote search for customers, places, or products needs filtering and careful request handling. In both cases, the component must make selection behaviour clear rather than treating autocomplete as a visual enhancement.

Practical rule: Treat the suggestion list as part of the form’s interaction model, not as an overlay added after the input is finished.

The UK government’s accessibility acceptance criteria describe explicit pass conditions for autocomplete. The field must support keyboard focus, indicate focus, announce that autocomplete is available, explain how to use it, report when it expands, announce matches or no matches, and allow users to move through and confirm suggestions with touch or keyboard. Those requirements are documented in the GOV.UK accessibility acceptance criteria for autocomplete.

The three implementations in this guide share the same contract:

  • Vanilla custom element: You own state, rendering, events, and ARIA updates.
  • DOM Studio headless primitive: The component supplies framework-agnostic behaviour while your markup and styling remain flexible.
  • Vue wrapper: Reactive props, v-model, and slots fit the same interaction model into a Vue application.

The framework changes. The accessibility obligations don’t.

Prerequisites for Implementation

Start with a small, testable environment rather than placing combobox logic directly into a complex form. You’ll need a modern browser target, a build tool that supports custom elements, and a test runner capable of driving real keyboard events. If your product supports older browsers, confirm custom-element and accessibility API requirements before choosing a polyfill, because a polyfill can reproduce DOM behaviour without reproducing every assistive-technology interaction.

A practical setup includes:

  • Browser support: Test Chromium, Firefox, and WebKit through Playwright. Include the browser versions your support policy names.
  • Component runtime: Use native custom elements for the vanilla version, DOM Studio for the headless version, and Vue with its official tooling for the wrapper.
  • Static analysis: Enable ESLint, keyboard-event rules, and checks that flag missing labels or invalid ARIA relationships.
  • Accessibility checks: Add axe-core to automated page checks, then verify announcements and focus behaviour with manual screen-reader testing.
  • Network tests: Mock delayed, empty, reordered, and failed suggestion responses. Autocomplete bugs often appear when responses arrive out of sequence.

Keep the initial scaffold deliberately boring:

<label for="customer-search">Customer</label>
<input
  id="customer-search"
  type="text"
  role="combobox"
  aria-autocomplete="list"
  aria-expanded="false"
  aria-controls="customer-options"
  autocomplete="off"
/>
<ul id="customer-options" role="listbox" hidden></ul>

The label gives the field an accessible name. The input owns the user’s text, while the listbox owns the available choices. Before adding styling, confirm that the browser can focus the input and that your test harness can inspect every state transition.

Markup and ARIA Roles

The safest structure starts with a real text input. Give it a visible label, role="combobox", and aria-autocomplete="list" when the user types freely and receives a list of matching options. aria-expanded describes whether the popup is open, while aria-controls identifies the listbox it controls.

<label for="place">Place</label>
<input
  id="place"
  role="combobox"
  type="text"
  aria-autocomplete="list"
  aria-expanded="false"
  aria-controls="place-list"
  aria-describedby="place-help"
/>
<p id="place-help">Type a place, then use the arrow keys to review suggestions.</p>

<ul id="place-list" role="listbox" hidden></ul>

Each suggestion needs a stable identifier and role="option". When focus remains on the input, aria-activedescendant points to the currently highlighted option. That pattern avoids moving DOM focus into the popup, which makes typing and dismissal easier to manage.

<ul id="place-list" role="listbox">
  <li id="place-1" role="option" aria-selected="false">Bath</li>
  <li id="place-2" role="option" aria-selected="true">Birmingham</li>
</ul>

Update both the visual state and aria-selected when the active option changes:

input.setAttribute('aria-activedescendant', 'place-2');

options.forEach((option, index) => {
  const active = index === activeIndex;
  option.setAttribute('aria-selected', String(active));
  option.classList.toggle('is-active', active);
});

Use aria-owns only when the listbox isn’t controlled through the DOM relationship exposed by aria-controls, such as a popup rendered elsewhere. Don’t add both attributes by habit. Each relationship should describe the actual structure your component renders.

A diagram illustrating the accessible markup structure for a web combobox with an associated listbox of items.

State announcements that users can understand

A live region can report changes that aren’t obvious from the input’s value. Keep it visually hidden but available to assistive technology, and update it when results arrive, when the list expands, and when no result matches.

<p id="place-status" class="visually-hidden" aria-live="polite"></p>
function announce(message) {
  status.textContent = '';
  requestAnimationFrame(() => {
    status.textContent = message;
  });
}

function showResults(items) {
  list.hidden = items.length === 0;
  input.setAttribute('aria-expanded', String(items.length > 0));

  if (items.length) {
    announce('Suggestions available.');
  } else {
    announce('No suggestions match your input.');
  }
}

Don’t rely on announcing only a result count. The GOV.UK guidance requires the component to announce availability and matches, report expansion, and support navigation and confirmation with touch or keyboard. The accessible dropdown menu guidance from DOM Studio is also useful when the popup shares positioning, focus, or dismissal patterns with other menu-like controls.

A long option label must remain recognisable as one option. Give each item enough width, allow wrapping without confusing the boundary between rows, and ensure the active styling survives high contrast and forced-colour modes.

Handling Keyboard Interactions

Keyboard behaviour should be designed as a state machine. The input owns focus, the listbox owns the options, and aria-activedescendant reflects the active option whenever the popup is open. Avoid moving focus into each result with tabindex values. That approach can make the control feel like two separate widgets and often complicates Escape, blur, and form submission.

A useful event handler looks like this:

input.addEventListener('keydown', event => {
  const open = input.getAttribute('aria-expanded') === 'true';

  switch (event.key) {
    case 'ArrowDown':
      event.preventDefault();
      if (!open) openList();
      moveActive(1);
      break;

    case 'ArrowUp':
      event.preventDefault();
      if (!open) openList();
      moveActive(-1);
      break;

    case 'Home':
      if (open) {
        event.preventDefault();
        setActive(0);
      }
      break;

    case 'End':
      if (open) {
        event.preventDefault();
        setActive(options.length - 1);
      }
      break;

    case 'Enter':
      if (open && activeIndex >= 0) {
        event.preventDefault();
        chooseActive();
      }
      break;

    case 'Escape':
      if (open) {
        event.preventDefault();
        closeList();
      }
      break;
  }
});

moveActive should clamp or deliberately wrap the index, then update aria-activedescendant and scroll the option into view:

function setActive(index) {
  activeIndex = Math.max(0, Math.min(index, options.length - 1));
  const option = options[activeIndex];

  input.setAttribute('aria-activedescendant', option.id);
  options.forEach(item => {
    item.setAttribute('aria-selected', String(item === option));
  });

  option.scrollIntoView({ block: 'nearest' });
  announce(`${option.textContent}, suggestion`);
}

Whether you wrap from the last item to the first is a product decision. Consistency matters more than the choice. Home and End should behave predictably, and Enter should select only when an active option exists. If no result is highlighted, pressing Enter should preserve the typed value or submit the surrounding form according to the product’s documented behaviour.

A diagram illustrating keyboard interaction flow for a combobox component, showing keys and their associated actions.

Keep focus on the input, expose the active option through aria-activedescendant, and restore the collapsed state without stealing focus.

Pointer interaction needs the same state model. Use pointerdown or prevent the input’s blur from closing the popup before a click can select an option. On close, remove aria-activedescendant, set aria-expanded="false", hide the listbox, and leave focus on the input unless the user explicitly moved elsewhere.

Optimising Performance

Autocomplete should feel immediate without turning every keystroke into a server request. Debounce input events, cancel stale requests where possible, and ignore responses that no longer match the current query. The exact delay depends on the product and the backend, so measure typing behaviour and response timing instead of copying a constant from another application.

function debounce(fn, delay) {
  let timer;

  return (...args) => {
    window.clearTimeout(timer);
    timer = window.setTimeout(() => fn(...args), delay);
  };
}

const requestSuggestions = debounce(async query => {
  const response = await fetch(`/api/places?q=${encodeURIComponent(query)}`);
  const items = await response.json();
  renderSuggestions(items);
}, 200);

The debounce prevents a burst of input events from immediately starting a request for every character. It doesn’t solve response ordering by itself. Use an AbortController, a request token, or a query comparison before rendering.

let latestQuery = '';

async function loadSuggestions(query) {
  latestQuery = query;
  const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
  const items = await response.json();

  if (query !== latestQuery) return;
  renderSuggestions(items);
}

A visual comparison infographic illustrating how debounce utility and list virtualization optimize autocomplete performance in web applications.

Rendering without overwhelming the DOM

For a large result set, render a window around the visible area rather than creating every option node at once. A virtualised list needs a predictable item height, a scroll container, and a spacer that preserves the total scroll range. If option heights vary, use measured rows or choose a simpler paginated result design.

function renderWindow(items, start, visibleCount) {
  const end = Math.min(start + visibleCount, items.length);
  const visible = items.slice(start, end);

  list.innerHTML = visible.map((item, offset) => {
    const index = start + offset;
    return `
      <li
        id="option-${index}"
        role="option"
        aria-selected="false"
        style="transform: translateY(${index * rowHeight}px)"
      >${escapeHtml(item.label)}</li>
    `;
  }).join('');
}

Virtualisation must preserve accessibility, not just paint fewer nodes. When the active option scrolls outside the rendered window, update the window before assigning aria-activedescendant. Otherwise, the input can reference an element that no longer exists.

The DOM Studio performance optimisation guidance provides a useful reference for isolating rendering, event handling, and bundle concerns. Also test enlarged content and high zoom. The GOV.UK publishing design guide records a December 2024 update to its search autocomplete after suggestion popups broke at high zoom, a reminder that responsive layout and accessibility can fail together. See the GOV.UK search autocomplete component guidance.

Integration Examples in Vanilla DOM Studio and Vue

The same combobox contract can be implemented at three different abstraction levels. A vanilla custom element gives maximum control, a headless primitive centralises interaction behaviour, and a Vue wrapper makes state flow natural inside a reactive application.

A developer working on a large computer monitor displaying code for Vanilla JavaScript, Headless DOM, and Vue.js.

Vanilla custom element

A custom element can encapsulate private state while exposing simple attributes and events. Shadow DOM helps prevent accidental style collisions, but it also means you must check how labels, form participation, and assistive technology behave in your browser support matrix.

class SearchCombobox extends HTMLElement {
  #input;
  #list;
  #items = [];
  #active = -1;

  connectedCallback() {
    this.innerHTML = `
      <label for="search-input">Search</label>
      <input id="search-input" role="combobox"
        aria-autocomplete="list"
        aria-expanded="false"
        aria-controls="search-list">
      <ul id="search-list" role="listbox" hidden></ul>
    `;

    this.#input = this.querySelector('input');
    this.#list = this.querySelector('ul');

    this.#input.addEventListener('input', event => {
      this.dispatchEvent(new CustomEvent('query-change', {
        detail: { query: event.target.value }
      }));
    });
  }

  set items(value) {
    this.#items = value;
    this.#render();
  }

  #render() {
    this.#list.innerHTML = this.#items.map(item =>
      `<li role="option" id="${item.id}">${item.label}</li>`
    ).join('');
  }
}

customElements.define('search-combobox', SearchCombobox);

This approach is appropriate when you need a framework-neutral package or unusual selection rules. The trade-off is maintenance. You’re responsible for focus transitions, keyboard semantics, async race conditions, form integration, and regression tests.

DOM Studio headless primitive

A headless primitive moves those interaction details into a reusable component while leaving presentation to the consuming application. A conceptual integration keeps the page markup small:

<dom-combobox
  label="Search"
  autocomplete="list"
  placeholder="Start typing"
></dom-combobox>

The application supplies data and listens for selection:

const combobox = document.querySelector('dom-combobox');

combobox.items = places;

combobox.addEventListener('change', event => {
  const selected = event.detail;
  submitPlace(selected);
});

This is the main trade-off. You give up some freedom to reimplement the interaction model, but you gain one place to maintain ARIA relationships and keyboard handling. DOM Studio’s headless primitives are standards-based custom elements, so the behaviour can sit below a vanilla page or a framework integration. If your interface includes command-style search as well, its command palette component guidance illustrates how a related listbox interaction can share the same focus discipline.

Vue wrapper

A Vue wrapper should make the controlled state visible without forcing consumers to manually mirror every ARIA attribute. A typical usage pattern might look like this:

<script setup>
import { ref } from 'vue'
import { Combobox } from '@dom-studio/vue'

const query = ref('')
const selected = ref(null)
const places = ref([])
</script>

<template>
  <Combobox
    v-model="selected"
    v-model:query="query"
    :items="places"
    label="Place"
    item-label="name"
  >
    <template #option="{ item, active }">
      <span :class="{ 'is-active': active }">{{ item.name }}</span>
    </template>
  </Combobox>
</template>

The wrapper suits teams already using Vue’s reactive data flow and slots for visual customisation. Keep the wrapper thin. If it invents a second keyboard model instead of forwarding the primitive’s events, the two implementations will drift.

Address autocomplete deserves domain-specific validation as well as accessibility. For checkout and delivery forms, a resource on optimising customer address accuracy can help teams think through selection data, confirmation, and the difference between a suggested label and a canonical address. The component should still let users correct or replace a suggestion rather than treating the first match as unquestionable truth.

Choose vanilla when you need a self-contained, framework-neutral control and have the capacity to own its tests. Choose a headless primitive when consistency across applications matters. Choose the Vue wrapper when your product already depends on Vue reactivity, slots, and component conventions. In every option, preserve the same input, listbox, option, focus, announcement, and selection contract.

Testing and Edge Case Strategies

Test the interaction as a user would use it, not only as the DOM appears after rendering. Playwright can focus the field, type a query, press ArrowDown, confirm the active descendant, and select with Enter.

test('selects a suggestion with the keyboard', async ({ page }) => {
  await page.goto('/search');
  const input = page.getByRole('combobox');

  await input.fill('Bir');
  await expect(input).toHaveAttribute('aria-expanded', 'true');

  await input.press('ArrowDown');
  await expect(input).toHaveAttribute('aria-activedescendant', /option/);

  await input.press('Enter');
  await expect(input).toHaveAttribute('aria-expanded', 'false');
});

Run axe-core against the open and closed states. Add assertions for the accessible name, aria-controls, option roles, active descendant, no-match status, and focus after Escape. Manual screen-reader testing remains necessary because a passing rule set cannot tell you whether the announcement is understandable in context.

The GOV.UK team’s 2018 autocomplete accessibility testing report found that country names could wrap onto two lines and become difficult to recognise as separate suggestions. Test long labels, narrow containers, forced colours, touch input, and enlarged content. Treat wrapping as a list-design problem, not merely a typography issue.

Also test:

  • No matches: Announce the state and close or retain the list according to a consistent rule.
  • Slow responses: Keep the input usable and prevent stale results from replacing newer ones.
  • Outside clicks: Close the popup without breaking the next focus target.
  • High zoom: Ensure the popup stays attached to the field and doesn’t cover essential controls.
  • Form submission: Decide whether free text is valid and test Enter in both open and closed states.

DOM Studio provides headless combobox and autocomplete primitives that can carry the ARIA, focus, and keyboard patterns across vanilla DOM and Vue interfaces while leaving presentation to your team. Visit DOM Studio to evaluate the custom-element and Vue approaches, then use the same test cases to validate your production combobox with autocomplete.