Dropdown with Alpine.js
A dropdown that only works with a mouse is useless to keyboard users and screen reader users. The WAI-ARIA listbox pattern defines precisely how arrow keys, Home, End, Escape, and type-ahead must behave in a dropdown, and Alpine.js makes the implementation surprisingly elegant.
Table of Contents
- 1. The WAI-ARIA listbox pattern: what the standard requires
- 2. Basic structure: trigger, listbox, and options
- 3. Keyboard handler: arrow keys, Home, End, Escape
- 4. Type-ahead: jumping to options by letter
- 5. ARIA attributes: the complete implementation
- 6. Focus management and scroll-into-view
- 7. Multi-column and grouped dropdowns
- 8. Combobox: search combined with a dropdown
- 9. Keyboard pattern comparison
- 10. Summary
- 11. FAQ
1. The WAI-ARIA listbox pattern: what the standard requires
The WAI-ARIA Authoring Practices define a precise keyboard interaction pattern for listboxes and dropdowns. A role="listbox" element must support the following keyboard interactions: the down arrow key moves focus to the next option, the up arrow key to the previous one. Home moves focus to the first option, End to the last. Enter and Space select the focused option. Escape closes the listbox without a selection. Printable characters (letters and digits) trigger type-ahead navigation.
What sets this pattern apart from a simple dropdown built with @click.outside="close()" is its completeness: every keyboard interaction is specified and expected. Screen reader users rely on this because every listbox has to follow the same standard. A dropdown that only supports mouse clicks is functionally inaccessible to keyboard users, and it violates WCAG 2.1 success criterion 2.1.1 (Keyboard).
In Alpine.js, this pattern can be implemented entirely without external libraries. The component logic consists of state management (open, activeIndex, selectedIndex), the keyboard handlers, and the ARIA attribute bindings. Alpine.js's reactive system ensures that focus and ARIA attributes always stay in sync with activeIndex, without manual DOM manipulation.
2. Basic structure: trigger, listbox, and options
The HTML structure of a keyboard-accessible dropdown in Alpine.js consists of three parts. The trigger button opens and closes the listbox and carries aria-haspopup="listbox", :aria-expanded="open", and :aria-controls="listboxId". The listbox itself has role="listbox", :aria-activedescendant="activeOptionId", and the matching ID. Each option has role="option", a unique ID, :aria-selected="isSelected(index)", and a tabindex="-1".
The aria-activedescendant attribute on the listbox is the ARIA mechanism that tells screen readers which option is currently "active" (focused) without actually moving DOM focus onto the option. This makes it possible to keep focus on the listbox while still communicating navigation between options. In a simpler implementation you could move DOM focus directly to the options instead, which does not require aria-activedescendant, but needs more focus management code.
// Complete keyboard-accessible dropdown
function keyboardDropdown(options = []) {
return {
open: false,
activeIndex: -1,
selectedIndex: -1,
options: options,
listboxId: `listbox-${Math.random().toString(36).slice(2, 7)}`,
typeAheadBuffer: '',
typeAheadTimer: null,
// Getter for aria-activedescendant
get activeOptionId() {
if (this.activeIndex < 0) return null;
return `${this.listboxId}-option-${this.activeIndex}`;
},
get selectedLabel() {
if (this.selectedIndex < 0) return 'Bitte wählen...';
return this.options[this.selectedIndex]?.label ?? 'Bitte wählen...';
},
toggle() {
this.open ? this.close() : this.openDropdown();
},
openDropdown() {
this.open = true;
// Set active option to the selected one, or the first
this.activeIndex = this.selectedIndex >= 0 ? this.selectedIndex : 0;
this.$nextTick(() => {
this.$el.querySelector(`[role="listbox"]`)?.focus();
this.scrollActiveIntoView();
});
},
close() {
this.open = false;
this.activeIndex = -1;
// Return focus to the trigger
this.$el.querySelector('button[aria-haspopup]')?.focus();
},
selectOption(index) {
this.selectedIndex = index;
this.$dispatch('vendor:option-selected', {
value: this.options[index]?.value,
label: this.options[index]?.label
});
this.close();
},
isSelected(index) {
return this.selectedIndex === index;
},
isActive(index) {
return this.activeIndex === index;
}
};
}
3. Keyboard handler: arrow keys, Home, End, Escape
The keyboard handler is the heart of the accessible dropdown. It sits on the role="listbox" element and intercepts every relevant key. Alpine.js syntax keeps this readable: @keydown.arrow-down.prevent, @keydown.arrow-up.prevent, @keydown.home.prevent, @keydown.end.prevent, @keydown.enter.prevent, @keydown.space.prevent, @keydown.escape. The .prevent modifier stops the default browser behavior (scrolling) for the navigation keys.
The wrapping behavior on arrow keys matters here: if the user presses the down arrow while on the last option, focus should jump to the first option, and vice versa. This is the standard WAI-ARIA pattern and what screen reader users expect. Alternatively you can implement it without wrapping (the last element stays put on the down arrow); both variants are spec-compliant, but wrapping is the more common choice for short lists.
// Keyboard handler methods (part of the keyboardDropdown component)
handleKeydown(event) {
if (!this.open) {
// Dropdown closed: Space, Enter, and arrow keys open it
if (['ArrowDown', 'ArrowUp', ' ', 'Enter'].includes(event.key)) {
event.preventDefault();
this.openDropdown();
}
return;
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.activeIndex = (this.activeIndex + 1) % this.options.length;
this.scrollActiveIntoView();
break;
case 'ArrowUp':
event.preventDefault();
this.activeIndex = this.activeIndex <= 0
? this.options.length - 1
: this.activeIndex - 1;
this.scrollActiveIntoView();
break;
case 'Home':
event.preventDefault();
this.activeIndex = 0;
this.scrollActiveIntoView();
break;
case 'End':
event.preventDefault();
this.activeIndex = this.options.length - 1;
this.scrollActiveIntoView();
break;
case 'Enter':
case ' ':
event.preventDefault();
if (this.activeIndex >= 0) {
this.selectOption(this.activeIndex);
}
break;
case 'Escape':
this.close();
break;
case 'Tab':
// Tab closes the dropdown, focus moves to the next element
this.open = false;
this.activeIndex = -1;
break;
default:
// Printable characters trigger type-ahead
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
this.handleTypeAhead(event.key);
}
}
},
scrollActiveIntoView() {
this.$nextTick(() => {
const activeEl = this.$el.querySelector(`#${this.activeOptionId}`);
activeEl?.scrollIntoView({ block: 'nearest' });
});
}
4. Type-ahead: jumping to options by letter
Type-ahead is the keyboard pattern that lets users jump straight to a matching option by typing letters. If a user types "S", focus jumps to the first option starting with "S". If they quickly type "Sc", focus jumps to the first option starting with "Sc". If the same key is pressed repeatedly, all matching options are cycled through in turn.
The Alpine.js implementation uses a type-ahead buffer with a timeout: pressed letters accumulate into a string. After a short pause (typically 300 to 500ms), the buffer is cleared. On every new character, the buffer is used to search for a matching option. This requires the option labels to be normalized for comparison (toLowerCase, stripping diacritics).
// Type-ahead implementation
handleTypeAhead(char) {
// Extend the buffer with the new character
this.typeAheadBuffer += char.toLowerCase();
// Reset the previous timer
clearTimeout(this.typeAheadTimer);
// Look for a match among the options
const match = this.findTypeAheadMatch(this.typeAheadBuffer);
if (match !== -1) {
this.activeIndex = match;
this.scrollActiveIntoView();
}
// Clear the buffer after 500ms
this.typeAheadTimer = setTimeout(() => {
this.typeAheadBuffer = '';
}, 500);
},
findTypeAheadMatch(buffer) {
// Normalize for comparison (lowercase, strip diacritics)
const normalize = (str) => str
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '');
const normalizedBuffer = normalize(buffer);
// Search for a match starting from the current index (for cyclic traversal)
const startFrom = buffer.length === 1 ? this.activeIndex + 1 : 0;
for (let i = 0; i < this.options.length; i++) {
const idx = (startFrom + i) % this.options.length;
const label = normalize(this.options[idx].label);
if (label.startsWith(normalizedBuffer)) {
return idx;
}
}
return -1; // No match
},
5. ARIA attributes: the complete implementation
A complete ARIA implementation for a listbox dropdown requires attributes on three levels. On the trigger button: aria-haspopup="listbox" (announces that a listbox popup follows), :aria-expanded="open" (current state), and :aria-controls="listboxId" (ID of the listbox). On the listbox itself: role="listbox", :id="listboxId", :aria-activedescendant="activeOptionId" (active option), and tabindex="0" (focusable). On each option: role="option", a unique :id, and :aria-selected="isSelected(index)".
A common mistake: setting aria-selected only on the selected option and omitting it everywhere else. The correct standard is to set aria-selected="false" explicitly on every unselected option instead of simply leaving the attribute off. Screen readers handle a missing aria-selected inconsistently; an explicit false is more reliable. In Alpine.js: :aria-selected="isSelected(index) ? 'true' : 'false'".
6. Focus management and scroll-into-view
When a listbox is long and not all options are visible at once, the active option must be scrolled into view automatically. element.scrollIntoView({ block: 'nearest' }) is the correct solution: block: 'nearest' scrolls the element just far enough to become visible, without scrolling any further than necessary. This is the natural behavior users already know from native select elements.
Focus management in a keyboard dropdown has one important nuance: with the aria-activedescendant strategy, DOM focus stays on the role="listbox" element. Screen readers announce the active option because aria-activedescendant points at the option's ID. When the dropdown closes (Escape or a selection), focus must return to the trigger button. This ensures keyboard users don't lose their place on the page after making a selection.
7. Multi-column and grouped dropdowns
Some dropdowns show options in groups (for example, by category) or across multiple columns. The ARIA structure for groups uses role="group" with aria-label inside the listbox. Options within a group are still marked with role="option". Keyboard navigation moves through all options in document order, regardless of the visual column layout; that is the standard and expected behavior.
For multi-column layouts, the Alpine.js implementation is identical to the single-column version; only the CSS changes. The logic for activeIndex, the keyboard handlers, and the ARIA attributes all stay the same. This is one advantage of component design: presentation (columns in a CSS grid) is fully decoupled from behavior (keyboard navigation in the Alpine.js component).
// Grouped dropdown with categories
function groupedDropdown() {
return {
open: false,
activeIndex: -1,
selectedIndex: -1,
listboxId: `grouped-listbox-${Math.random().toString(36).slice(2, 7)}`,
// Flat list of all options (for simple index-based navigation)
groups: [
{
label: 'Deutschland',
options: [
{ value: 'de-ber', label: 'Berlin' },
{ value: 'de-ham', label: 'Hamburg' },
{ value: 'de-muc', label: 'München' }
]
},
{
label: 'Österreich',
options: [
{ value: 'at-vie', label: 'Wien' },
{ value: 'at-gra', label: 'Graz' }
]
}
],
// Flat option list for navigation
get flatOptions() {
return this.groups.flatMap(g => g.options);
},
get activeOptionId() {
return this.activeIndex >= 0
? `${this.listboxId}-option-${this.activeIndex}`
: null;
},
get selectedLabel() {
if (this.selectedIndex < 0) return 'Stadt wählen...';
return this.flatOptions[this.selectedIndex]?.label;
},
// Find the flat-list index for a given group/option pair
flatIndex(groupIndex, optionIndex) {
let flat = 0;
for (let g = 0; g < groupIndex; g++) {
flat += this.groups[g].options.length;
}
return flat + optionIndex;
},
selectByFlatIndex(flatIndex) {
this.selectedIndex = flatIndex;
this.close();
this.$dispatch('vendor:city-selected', {
value: this.flatOptions[flatIndex]?.value
});
}
};
}
8. Combobox: search combined with a dropdown
A combobox combines a text input (for free-text search) with a listbox (for selecting an option). It is the most complex ARIA pattern and the most common one in e-commerce projects: product search, address autocomplete, category filtering. WAI-ARIA defines its own keyboard pattern for comboboxes, distinct from the plain listbox.
In the combobox pattern, focus stays on the input element rather than on the listbox. The down arrow key opens the listbox and moves the active state to the first option (either via actual DOM focus, or via aria-activedescendant on the first option without moving DOM focus). The input remains focused, so the user can keep typing. Enter selects the active option. Alpine.js implements this by separating inputFocused from activeIndex.
| Key | Listbox behavior | Combobox behavior | Alpine.js handler |
|---|---|---|---|
| Arrow ↓ | Next option (wrapping) | Opens / next option | @keydown.arrow-down.prevent |
| Arrow ↑ | Previous option (wrapping) | Previous option | @keydown.arrow-up.prevent |
| Home | First option | First option | @keydown.home.prevent |
| End | Last option | Last option | @keydown.end.prevent |
| Escape | Closes, focus to trigger | Closes, resets input content | @keydown.escape |
Mironsoft
Alpine.js, ARIA, and accessible Hyvä components
Need accessible UI components for your Hyvä project?
We build fully keyboard-accessible dropdowns, comboboxes, and listboxes to the WAI-ARIA standard, for Hyvä themes and Magento 2 projects.
UI Components
Listboxes, comboboxes, menus, and tabs built to the WAI-ARIA standard
WCAG 2.2 AA
Full keyboard navigation and screen reader support
Hyvä Integration
CSP-compliant Alpine.js components for every Hyvä theme page
10. Summary
A fully keyboard-accessible dropdown in Alpine.js implements the WAI-ARIA listbox pattern: arrow keys for navigation, Home/End for the first/last option, Enter/Space for selection, Escape to close, and type-ahead for letter-based navigation. The Alpine.js implementation consists of a named component function with state (open, activeIndex, selectedIndex), keyboard handler methods, and reactive ARIA attribute bindings.
Type-ahead with a buffer and timeout is the most technically demanding piece, but it matters most to screen reader users working through long option lists. scrollIntoView({ block: 'nearest' }) keeps the active option visible at all times. Focus management on close (returning to the trigger) is the third critical piece. Together, these three elements produce a dropdown that is fully operable for every user, with or without a mouse.
Keyboard Dropdown with Alpine.js: The Essentials at a Glance
WAI-ARIA Listbox Pattern
Arrow keys ↑↓ navigate. Home/End jump to first/last. Enter/Space select. Escape closes. Printable characters trigger type-ahead.
ARIA Attributes
Trigger: aria-haspopup, aria-expanded, aria-controls. Listbox: role, aria-activedescendant. Options: role, id, aria-selected.
Type-Ahead
Buffer plus a 500ms timer. Normalization (toLowerCase, strip diacritics). Cyclic search from the current index. Repeated presses cycle through matches.
Focus Management
On open: focus moves to the listbox, activeIndex set to the selection or 0. On close: focus returns to the trigger. scrollIntoView keeps the active option visible.