No native element? Then full responsibility for role, state, and keyboard
Building your own dropdown or combobox instead of using a native HTML element means taking full responsibility for keyboard handling, focus, and screen reader announcements. This article shows step by step how to build an accessible custom widget following the WAI-ARIA Authoring Practices, with correct role, aria-expanded, aria-activedescendant, and real screen reader testing before shipping.
Table of Contents
- 1. Why custom widgets need special care
- 2. Anatomy of an accessible combobox following WAI-ARIA
- 3. Keyboard handling: arrow keys, Escape, Enter, and typeahead
- 4. Using aria-expanded, aria-activedescendant, and aria-selected correctly
- 5. Focus management: roving tabindex vs. aria-activedescendant
- 6. Implementation with Alpine.js in the Hyvä Theme
- 7. Testing with real screen readers: NVDA, VoiceOver, JAWS
- 8. Common mistakes with custom widgets
- 9. Native elements vs. custom widgets compared
- 10. Summary
- 11. FAQ
1. Why custom widgets need special care
Native HTML elements such as <select>, <input type="checkbox">, or <button> come with accessibility built in for free. The browser already knows their role, their state, and their keyboard behavior, and every screen reader has implemented this semantics correctly for decades. The moment a frontend team replaces a native element with its own freely styled dropdown or autocomplete combobox, because <select> cannot be styled enough for the design, that built-in support disappears entirely. From that moment on, the team itself is fully responsible for role, state, keyboard logic, and announcements to assistive technology.
In practice this often results in a clickable <div> that visually looks like a dropdown but remains effectively invisible to screen reader users, keyboard-only users, and users of switch devices. This pattern is informally called "divitis": elements with no semantic role that only respond to mouse clicks. The most reliable way out is to not invent your own interaction logic at all, but to adopt the established patterns of the WAI-ARIA Authoring Practices Guide (APG), developed by the W3C working group and tested for years across real browsers and screen readers.
2. Anatomy of an accessible combobox following WAI-ARIA
The APG defines, for common UI patterns like combobox, menu, tabs, or dialog, exactly which ARIA roles, states, and properties are required and in what order key presses trigger which effects. For a dropdown with free-text input, the combobox pattern with "list" autocomplete behavior is the relevant one: an <input> with role="combobox" controls a separate role="listbox" with role="option" child elements. This separation between the input field and the option list mirrors exactly what screen reader users expect if they are familiar with the native <select> or <datalist> behavior.
It matters that all three building blocks, role, state, and property, work together correctly: role="combobox" describes the type of widget, aria-expanded describes whether the list is visible, and aria-controls points to the listbox's ID. If any of these attributes is missing or incorrect, the screen reader either announces nothing or announces the wrong state. The following markup shows the minimal, correct structure following the APG combobox pattern.
<!-- WAI-ARIA APG: Editable combobox with list autocomplete -->
<div class="combobox-wrapper relative">
<label id="country-label" for="country-input">Country</label>
<input
id="country-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="country-listbox"
aria-autocomplete="list"
aria-activedescendant=""
aria-labelledby="country-label"
autocomplete="off"
>
<ul
id="country-listbox"
role="listbox"
aria-label="Countries"
class="hidden absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-lg"
>
<li id="option-de" role="option" aria-selected="false">Germany</li>
<li id="option-at" role="option" aria-selected="false">Austria</li>
<li id="option-ch" role="option" aria-selected="false">Switzerland</li>
</ul>
</div>
3. Keyboard handling: arrow keys, Escape, Enter, and typeahead
Keyboard handling is not optional for custom widgets, it is the actual definition of accessibility for every user who cannot or does not want to use a mouse. For the combobox, the APG requires at least: arrow down opens the list and moves the active option down, arrow up moves it up, Enter commits the active option, and Escape closes the list without a selection and returns focus to the input field. Home and End jump to the first and last option respectively, which noticeably improves usability with long lists of countries or products.
An often overlooked detail is typeahead: a user who types letters in quick succession expects the list to jump to the matching option, exactly like the native <select>. If this behavior is left out, the custom widget feels noticeably slower to experienced keyboard users than the native original, even if it looks correct visually. The implementation belongs entirely in a single keydown handler that uses a switch statement to handle every relevant key and leaves all other keys untouched.
// Keyboard handling per WAI-ARIA Authoring Practices combobox pattern
input.addEventListener('keydown', (event) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
openListbox();
moveActiveOption(1);
break;
case 'ArrowUp':
event.preventDefault();
moveActiveOption(-1);
break;
case 'Enter':
if (activeOptionId) {
selectOption(activeOptionId);
event.preventDefault();
}
break;
case 'Escape':
closeListbox();
input.focus();
break;
case 'Home':
event.preventDefault();
setActiveOption(options[0].id);
break;
case 'End':
event.preventDefault();
setActiveOption(options[options.length - 1].id);
break;
default:
// Typeahead: jump to the option starting with the typed character
if (event.key.length === 1) {
jumpToTypeahead(event.key);
}
}
});
4. Using aria-expanded, aria-activedescendant, and aria-selected correctly
aria-expanded must stay synchronized with the actual visible state of the list, never just with the intention. If the list is hidden via CSS with display: none but aria-expanded stays at "true", the screen reader announces an open list that effectively does not exist, causing complete confusion. Updating aria-expanded therefore belongs in exactly the same function that also toggles the CSS class for showing and hiding the list, never in two separate code paths.
aria-activedescendant is the central, but also the most frequently misunderstood attribute of the pattern. The actual DOM focus stays in the <input> field the entire time, while aria-activedescendant points by ID to the currently highlighted <li role="option">. The screen reader then reads it as if focus were actually on the option, even though keystrokes are still processed by the input field. aria-selected finally marks the option that has actually been chosen after confirming with Enter, and must be distinguished from the merely temporarily "active" option indicated via aria-activedescendant. The visual highlight of the active option must be recreated with a CSS class, because the browser draws no native focus ring for it.
/* Visual highlight must mirror aria-activedescendant,
the browser draws no native focus ring for this */
[role="option"][aria-selected="true"] {
background-color: #e4e4e7;
color: #18181b;
font-weight: 600;
}
.combobox-option--active {
background-color: #f4f4f5;
outline: 2px solid #3f3f46;
outline-offset: -2px;
}
/* Never remove the visible focus indicator on the real input */
#country-input:focus-visible {
outline: 2px solid #71717a;
outline-offset: 2px;
}
5. Focus management: roving tabindex vs. aria-activedescendant
There are two recognized strategies for managing keyboard focus in composite widgets: roving tabindex and aria-activedescendant. With roving tabindex, only one element in the group gets tabindex="0" at a time, all others get tabindex="-1", and the DOM focus actually jumps to the next element on every arrow key press. With aria-activedescendant, as described in the previous section, DOM focus stays constantly on a container element, while only an ARIA reference marks the active descendant.
For an editable combobox with text input, aria-activedescendant is almost always the right choice, because focus must stay in the <input> for typing to keep working. Roving tabindex is better suited to pure selection groups without text input, such as a toolbar or a radiogroup-style menu. The two strategies should never be mixed: a widget that partly moves DOM focus and partly uses aria-activedescendant produces inconsistent and unpredictable announcements in screen readers, because the software cannot tell which signal to trust.
6. Implementation with Alpine.js in the Hyvä Theme
In a Hyvä Theme, the entire combobox pattern can be encapsulated as a self-contained Alpine.js component without loading jQuery or any additional JavaScript libraries. The state, meaning open or closed, active option, and filtered list, lives entirely in x-data, while ARIA attributes are dynamically bound to the actual state through x-bind (short :attribute). This means aria-expanded can never fall out of sync, because it is computed directly from the same reactive variable that also drives the CSS class for x-show.
Important for Hyvä projects: the complete keydown handler, the typeahead behavior, and the ID generation for aria-activedescendant belong in a reusable Alpine component registered globally via alpine:init, instead of duplicating them in every .phtml template. That keeps the pattern consistent across every custom dropdown in the store, from the country selector in checkout to product filtering in category navigation.
<!-- Hyva phtml: accessible combobox as Alpine.js component -->
<div
x-data="accessibleCombobox({ options: <?= /* @noEscape */ $block->getCountryOptionsJson() ?> })"
class="relative"
>
<label :id="labelId" x-text="label" class="block text-sm font-medium mb-1"></label>
<input
type="text"
role="combobox"
:aria-expanded="open ? 'true' : 'false'"
:aria-activedescendant="activeId"
aria-autocomplete="list"
:aria-controls="listboxId"
:aria-labelledby="labelId"
x-model="query"
x-on:keydown="handleKeydown($event)"
x-on:focus="open = true"
x-on:click.outside="open = false"
class="border border-gray-300 rounded-lg px-3 py-2 w-full"
>
<ul
x-show="open"
:id="listboxId"
role="listbox"
class="absolute z-10 bg-white border border-gray-200 rounded-lg mt-1 w-full"
>
<template x-for="option in filteredOptions" :key="option.id">
<li
:id="option.id"
role="option"
:aria-selected="option.id === activeId"
x-text="option.label"
x-on:click="selectOption(option)"
x-on:mouseenter="activeId = option.id"
class="px-3 py-2 cursor-pointer"
:class="{ 'bg-zinc-100': option.id === activeId }"
></li>
</template>
</ul>
</div>
7. Testing with real screen readers: NVDA, VoiceOver, JAWS
No automated tool can fully verify the accessibility of a custom widget, because tools like axe-core or Lighthouse only detect static ARIA rule violations, such as missing labels or invalid attribute values. Whether a screen reader actually announces the active option in an understandable way when the arrow keys are pressed, whether the order of announcements makes sense, and whether Escape correctly resets focus, can only be found out by manually testing with a real screen reader. Automated checks are a useful first line of defense in the CI pipeline, but they are no substitute for the manual test.
For production use, a combination of at least NVDA with Firefox on Windows (free, widely used), VoiceOver with Safari on macOS or iOS (pre-installed on every Apple operating system), and ideally JAWS with Chrome is recommended, since JAWS is still the standard in many enterprise and government environments. A realistic test flow: Tab to the input field, use arrow keys to navigate through the options, Enter to select, Escape to cancel, while listening closely to whether the screen reader correctly reads out the position, name, and state of every option.
{
"testRunner": "playwright-axe",
"target": "https://shop.example.com/checkout/country-combobox",
"automatedRules": {
"aria-required-attr": "error",
"aria-valid-attr-value": "error",
"aria-command-name": "error",
"listitem": "error"
},
"manualScreenReaderChecks": [
"NVDA + Firefox: option announced with position, e.g. Germany, 1 of 3",
"VoiceOver + Safari: aria-activedescendant moves the virtual cursor correctly",
"JAWS + Chrome: Escape closes the listbox and returns focus to the input",
"Keyboard only, no mouse used: Tab order stays predictable throughout"
]
}
8. Common mistakes with custom widgets
The most common mistake is an input field with no visible <label> and no aria-labelledby, where only placeholder text serves as the label. Placeholder text disappears the moment the user starts typing, and many screen reader and autofill combinations do not treat it as a proper label anyway. A second common mistake: aria-expanded is set when opening the widget, but forgotten when closing it via a click outside the widget, so the state permanently shows "true" even though the list has long been invisible.
A third, particularly tricky mistake concerns duplicate announcements: if both a native title tooltip and a separate aria-label with different text are set, some screen readers read both texts back to back, which sounds confusing. And finally: setting role="listbox" on an element but not giving the child elements role="option", instead using plain <div> or <span> elements with no role, produces a structure that is incomplete for the accessibility tree and, in many screen readers, announces no options at all.
9. Native elements vs. custom widgets compared
The choice between a native element and a custom widget with ARIA should never be a pure design decision, it must always factor in the additional implementation and testing effort. The following overview shows the critical points where homegrown widgets most often fail, and the correct implementation following the APG pattern for each.
| Requirement | Risky, hand rolled without APG | Recommendation per WAI-ARIA APG | Why |
|---|---|---|---|
| Dropdown selection | <div onclick> with no role |
role="combobox" + role="listbox" |
Screen reader recognizes widget type and state |
| Keyboard | Only mouse clicks work | Arrow keys, Enter, Escape, typeahead | Keyboard users are otherwise fully excluded |
| Visibility state | No aria-expanded |
aria-expanded synced to state |
Screen reader otherwise announces the wrong state |
| Active option | Highlighted visually only | aria-activedescendant exposes it programmatically |
Focus stays in the input, announcement stays correct |
| Labeling | Placeholder text as the only label | <label> or aria-labelledby |
Placeholder disappears and is often ignored |
In practice, it is worth asking this question before every new custom widget: can the desired design be achieved with a styled native <select> or with <input list="..."> (datalist)? Only if the answer is clearly no, for example because icons, grouping, or live filtering within the options are needed, does the extra effort of a full ARIA combobox pay off.
Mironsoft
Accessible frontend development for Magento and Hyvä stores
Custom widgets that are actually accessible?
We build and audit accessible custom widgets for Magento and Hyvä stores, from comboboxes to tabs to modals, following the WAI-ARIA Authoring Practices and with real screen reader testing before every release.
ARIA audit
Reviewing existing custom widgets for role, state, and keyboard handling
Hyvä implementation
Alpine.js components following APG patterns for comboboxes, menus, and dialogs
Screen reader testing
Manual testing with NVDA, VoiceOver, and JAWS before every release
10. Summary
Making a custom widget accessible with ARIA succeeds most reliably when you do not invent your own interaction logic, but instead adopt the established patterns of the WAI-ARIA Authoring Practices Guide. For a combobox that means: role="combobox" with a separate role="listbox", aria-expanded synced to the visible state, aria-activedescendant to mark the active option, and full keyboard support with arrow keys, Enter, Escape, Home, End, and typeahead. Roving tabindex and aria-activedescendant must never be mixed.
The decisive final step remains the manual test with a real screen reader. Automated tools like axe-core reliably find missing attributes and invalid values, but whether an announcement is actually understandable and the keyboard experience feels right can only be determined with NVDA, VoiceOver, or JAWS in real browsers. Running this test before every release, instead of relying on CI checks alone, avoids the most common and most expensive barriers in homegrown UI components.
Making Custom Widgets Accessible with ARIA: the key takeaways
Patterns, not invention
Adopt established APG patterns for combobox, menu, tabs, and more instead of inventing your own keyboard logic.
Set role, state, and property correctly
aria-expanded synced to visible state, aria-controls pointing to the listbox ID, aria-activedescendant pointing to the active option.
Keep focus management consistent
Roving tabindex or aria-activedescendant, never mixed. For editable comboboxes, focus stays in the input field.
Test with a real screen reader
Manually verify with NVDA, VoiceOver, and JAWS before release. axe-core is only the first line of defense.