Alpine.js Autocomplete Combobox: Accessible Search Suggestions
AI generated
x-data
Alpine
Alpine.js · ARIA · Accessibility · Combobox
Alpine.js Autocomplete Combobox
Accessible Search Suggestions

An autocomplete combobox sounds simple, until you have to bring together ARIA roles, keyboard navigation, screen reader announcements, and focus management. With Alpine.js, all of this can be solved declaratively, without a single line of jQuery.

12 min read x-data · x-show · @keydown · ARIA · WCAG 2.1 Alpine.js 3.x · Tailwind CSS · Hyvä

1. Why native autocomplete falls short

The HTML autocomplete attribute combined with a <datalist> element is the native way to add search suggestions, but it barely covers a single real-world use case in full. The appearance of the suggestion dropdown cannot be styled, the filter logic is limited to simple prefix matching, and accessibility attributes such as aria-activedescendant or aria-expanded are missing entirely. As soon as the design requirements go beyond a plain operating-system dropdown, or the filter logic needs fuzzy matching, highlighting, or remote data, a custom implementation becomes unavoidable.

The common reaction to this situation is to reach for a full component library like Select2 or Choices.js. Both, however, bring along jQuery or their own event systems, which are redundant and problematic on a Hyvä page that already uses Alpine.js. With Alpine.js, a complete, accessible autocomplete combobox can be built in under 80 lines of HTML and JavaScript logic inside x-data, without a single external dependency. The key lies in understanding the ARIA combobox pattern defined by the W3C WAI-ARIA Authoring Practices.

2. Understanding ARIA roles for comboboxes

The ARIA specification defines the combobox pattern as a combination of a text input with role="combobox" and an associated listbox with role="listbox". The input carries the attributes aria-expanded (true/false depending on the dropdown's visibility), aria-haspopup="listbox" (tells screen readers that a listbox follows) and aria-autocomplete="list" (describes the filter type). The aria-activedescendant attribute points to the ID of the currently highlighted list entry, so the screen reader can announce the focused item without the keyboard focus ever leaving the input field.

Each list entry gets role="option" and a unique ID. The aria-selected="true" attribute marks the selected entry. This combination of attributes is not optional: without it, a combobox is practically unusable for keyboard and screen reader users, and it fails a WCAG 2.1 audit under Success Criterion 4.1.2. With Alpine.js, all of these attributes can be bound directly to reactive state: :aria-expanded="open", :aria-activedescendant="activeId" and :aria-selected="item.id === selectedId" update automatically with every state change.


// Alpine.js combobox: full ARIA-compliant state
function combobox(items) {
  return {
    query: '',
    open: false,
    activeIndex: -1,
    selectedValue: null,

    // Filtered list derived from query
    get filtered() {
      const q = this.query.toLowerCase().trim();
      if (!q) return items.slice(0, 8);
      return items.filter(i =>
        i.label.toLowerCase().includes(q) ||
        i.keywords?.some(k => k.toLowerCase().includes(q))
      ).slice(0, 10);
    },

    // ID of currently highlighted option for aria-activedescendant
    get activeId() {
      if (this.activeIndex < 0 || !this.filtered[this.activeIndex]) return '';
      return `option-${this.filtered[this.activeIndex].id}`;
    },

    openList()  { this.open = true; this.activeIndex = -1; },
    closeList() { this.open = false; this.activeIndex = -1; },

    select(item) {
      this.selectedValue = item;
      this.query = item.label;
      this.closeList();
      this.$refs.input.focus();
    }
  };
}

3. Building the data structure and Alpine state

The cleanest approach is a function that returns the state as an object and gets called inside x-data. This avoids nested objects in a single x-data literal and lets you reuse the same state for multiple comboboxes on one page. The item list should already be present in the template as a JSON array on page load, so no visible loading flicker occurs. For remote data, the list starts out empty and gets populated via fetch, more on that in section 7.

Important for performance: the filtered list is defined as a get filtered() getter, not as a reactive variable. Alpine.js does not cache getter results automatically, but since the filter logic reacts to this.query and is only recalculated on input, this is not a practical problem for lists up to roughly 5,000 entries. For larger data sets, a debounced fetch request is recommended instead of client-side filtering.

4. Live filter logic without a debounce library

Alpine.js does not ship a built-in debounce, but the .debounce modifier for @input and @keyup has been available since Alpine 3.x: @input.debounce.300ms="onInput" automatically delays the call by 300 milliseconds after the last keystroke. For purely client-side filtering this is not necessary, since the computation happens synchronously in the same tick and triggers no network request. For API requests, however, the .debounce modifier is essential, so a request is not fired on every single keystroke.

Highlighting matches within the result text takes a bit more effort, because Alpine.js has no template function for innerHTML. The solution: a helper function highlight(text, query) returns a string with <mark> tags, which is then written into the entry via x-html. Important here: user input must be escaped before injection with text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') so it can be turned into a regular expression without producing pattern errors similar to SQL injection.


// Highlight matching text safely: prevents regex injection
function highlight(text, query) {
  if (!query.trim()) return text;
  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const regex = new RegExp(`(${escaped})`, 'gi');
  return text.replace(regex, '<mark class="bg-teal-100 text-teal-900 rounded px-0.5">$1</mark>');
}

// Debounced fetch for remote suggestions (Alpine @input.debounce.300ms)
async function fetchSuggestions(query) {
  if (query.length < 2) { this.items = []; return; }
  this.loading = true;
  try {
    const res = await fetch(`/api/suggestions?q=${encodeURIComponent(query)}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    this.items = await res.json();
  } catch (e) {
    console.error('Suggestion fetch failed:', e);
    this.items = [];
  } finally {
    this.loading = false;
  }
}

5. Keyboard navigation: Arrow, Enter, Escape

Keyboard navigation is the most involved part of an accessible combobox. According to the ARIA Authoring Practices, the following key combinations must work: ArrowDown opens the list and moves focus to the next entry, ArrowUp moves it to the previous one, Enter commits the active entry and closes the list, Escape closes the list without a selection, and Home and End jump to the first and last entry respectively. Throughout all of this, the actual DOM focus never leaves the input field, only aria-activedescendant changes, and the screen reader reads out the highlighted entry.

With Alpine.js, this is handled via @keydown.prevent on the input. The .prevent stops ArrowDown and ArrowUp from scrolling the page while the list is visible. Wrapping at the end of the list, from the last entry back to the first, noticeably improves usability, though it is optional per ARIA. In practice, wrapping is recommended because users are used to it from menus. The scroll-into-view logic for aria-activedescendant is handled by this.$refs.list.children[this.activeIndex]?.scrollIntoView({ block: 'nearest' }).


// Keyboard handler: attach via @keydown="handleKey($event)" on input
handleKey(e) {
  const len = this.filtered.length;
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      if (!this.open) { this.open = true; return; }
      this.activeIndex = (this.activeIndex + 1) % len;
      this.scrollActive();
      break;
    case 'ArrowUp':
      e.preventDefault();
      if (!this.open) return;
      this.activeIndex = (this.activeIndex - 1 + len) % len;
      this.scrollActive();
      break;
    case 'Enter':
      e.preventDefault();
      if (this.open && this.activeIndex >= 0) {
        this.select(this.filtered[this.activeIndex]);
      }
      break;
    case 'Escape':
      this.closeList();
      break;
    case 'Home':
      if (this.open) { e.preventDefault(); this.activeIndex = 0; this.scrollActive(); }
      break;
    case 'End':
      if (this.open) { e.preventDefault(); this.activeIndex = len - 1; this.scrollActive(); }
      break;
  }
},
scrollActive() {
  this.$nextTick(() => {
    this.$refs.list?.children[this.activeIndex]?.scrollIntoView({ block: 'nearest' });
  });
}

6. Focus management and closing on outside click

A combobox that does not close when clicking outside is unusable in practice. Alpine.js offers the @click.outside magic modifier for this, applied to the root element of the x-data block: @click.outside="closeList()". This is cleaner than a global document.addEventListener('click', ...), because Alpine automatically handles listener cleanup when the component is torn down, so no memory leaks occur.

When closing via the Tab key, when the user leaves the combobox without pressing Enter, the list must also close. @blur.capture="closeList()" does not handle this reliably, because the blur event also fires when clicking a list entry, before the click event arrives. The solution is a short delay: closeList() is called with setTimeout(() => { if (!this.$el.contains(document.activeElement)) this.closeList(); }, 100). Alpine's $nextTick is too short here; 100ms gives the click event enough time to fire first.

7. Loading asynchronous suggestions from an API

For server-side search suggestions, product search in a Magento shop for example, filtering is moved from the client to the server. The Alpine component then holds only the currently loaded suggestions as a reactive list and fires a fetch request on input. The @input.debounce.300ms modifier ensures a request is only sent after 300ms of typing silence. This significantly reduces server load during fast typing. At the same time, the component must ignore stale responses: if the user types "Alpine" and the response for "Alp" arrives after the response for "Alpine", the older results must not be displayed.

The simplest solution to this race condition problem is a request counter: each request gets an incrementing ID, and the then callback checks whether that ID still matches the current one. A cleaner alternative is AbortController: the previous request is aborted before every new call. Alpine's reactive system takes care of re-rendering as soon as this.items is set.

8. Grouped result lists with sections

When search suggestions come from different categories, products, categories, and CMS pages for example, a grouped display helps. The ARIA specification supports this with role="group" and aria-label inside the listbox. Each group gets a heading with role="presentation" (so screen readers do not treat it as an interactive element) followed by the associated role="option" entries.

In Alpine.js, the filtered list is structured for this as an object with group keys: { products: [...], categories: [...], pages: [...] }. In the template, x-for first iterates over the groups, then over the entries within each group. The activeIndex must reflect the flat position within the overall list, not the position within the group. A helper function flatIndex(group, indexInGroup) computes this: it sums the lengths of all preceding groups and adds the position within the current group.

9. Alpine combobox vs. external libraries

The most common objection to a custom implementation is development effort. In practice, however, a complete Alpine.js combobox with keyboard navigation, ARIA, and remote loading can be built in roughly 100 lines, often faster than integrating, configuring, and styling an external library. The decisive advantage: zero extra JavaScript bytes in the bundle, no conflicts with the Alpine lifecycle, and full control over markup and ARIA attributes.

Criterion Select2 / Choices.js Alpine.js native Alpine's advantage
Bundle size ~70-100 KB (+ jQuery) 0 KB extra Alpine.js already loaded
ARIA compliance Partial, often outdated Fully controllable Every attribute can be set deliberately
Tailwind styling Custom CSS classes, conflicts Native markup, full control No style overrides needed
Remote loading Built in, configurable fetch + Alpine, flexible Custom auth headers, AbortController
Hyvä compatibility Problematic (jQuery dependency) Native, no conflicts No CSP issues

The only reasonable exception for an external library is Headless UI or Radix (React based), but these do not apply to Alpine.js projects. Anyone building on Hyvä already has Alpine.js as the standard and does not need additional UI frameworks. The implementation described here is production ready, testable, and can be dropped into any page with a simple x-data="combobox(items)".

Mironsoft

Alpine.js Components · Hyvä Theme Development · Accessibility

Need accessible components for your Magento store?

We build Alpine.js components that meet WCAG 2.1 AA, integrate seamlessly with Hyvä, and bring no additional JavaScript dependencies.

Accessibility Audit

ARIA review and WCAG 2.1 analysis for existing Alpine components

Component Development

Autocomplete, modal, tabs, accordion, all Alpine native, no jQuery

Hyvä Integration

Seamless integration into existing Hyvä themes with no layout conflicts

10. Summary

An accessible autocomplete combobox with Alpine.js requires consistent work on three fronts: reactive state with correct ARIA attributes, complete keyboard navigation following the WAI-ARIA Authoring Practices, and focus management when opening and closing. All of this is achievable with Alpine.js without external libraries and integrates seamlessly with Hyvä themes and Tailwind CSS. The time invested pays off: a properly implemented combobox works equally well for mouse, keyboard, and screen reader users, and meets WCAG 2.1 AA.

The key takeaway: ARIA attributes are not optional extras, they are functional interfaces for assistive technology. aria-activedescendant, aria-expanded, and role="option" are just as important as the visual highlight of the active entry. Anyone using Alpine.js and binding these attributes to reactive state gets accessibility almost for free, because Alpine's reactive system automatically updates the attributes with every state change.

Alpine.js Autocomplete Combobox: The Essentials at a Glance

Required ARIA Attributes

role="combobox", aria-expanded, aria-haspopup="listbox", aria-activedescendant, all bound to Alpine state via : binding.

Keyboard Navigation

ArrowDown/Up, Enter, Escape, Home, End, handled via @keydown.prevent on the input field. Focus never leaves the input.

Outside Click & Blur

@click.outside="closeList()" on the root element. Blur uses a 100ms setTimeout instead of @blur, so click events are not interrupted.

Remote Loading

@input.debounce.300ms plus AbortController prevents stale responses. Eliminate race conditions with a request counter or an abort.

11. FAQ: Alpine.js Autocomplete Combobox

1Why isn't datalist enough?
Cannot be styled, no highlighting, no remote loading, no full ARIA attributes. For production-ready shops, Alpine.js is the better choice.
2Which ARIA attributes are required?
role="combobox", aria-expanded, aria-haspopup="listbox", aria-autocomplete="list" on the input; role="listbox" on the list; role="option" on each entry.
3Prevent race conditions in async suggestions?
AbortController cancels the previous fetch. Alternatively, a request counter, only the response to the most recent request is applied.
4Why @click.outside instead of a document listener?
Alpine removes the listener automatically on teardown. No memory leak, no manual removeEventListener needed.
5Implement highlighting in the result text?
highlight(text, query) escapes the input as a regex and replaces matches with mark tags. Insert the result via x-html.
6Group headings in the result list?
Filtered list as an object with group keys, x-for iterates over groups and entries in the template. Headings use role="presentation".
7aria-selected vs. aria-checked?
aria-selected belongs to role="option" in a listbox. aria-checked belongs to role="checkbox". The wrong role causes incorrect screen reader announcements.
8No results found, what to show?
An entry with role="option" and aria-disabled="true" with the text "No results found". Alternatively aria-live="polite" for a screen reader announcement.
9Use it in a Magento form?
Hidden input bound to selectedValue.id via x-model. The visible text field is only for input, the submit value lives in the hidden field.
10Test ARIA compliance without a screen reader?
Chrome DevTools Accessibility Tree, axe DevTools extension for automated WCAG checks, NVDA or VoiceOver for manual tests.