Making Custom Widgets Accessible with ARIA
AI generated
A11Y
WCAG
Accessibility · WAI-ARIA · Custom Widgets · Hyvä Theme
Making Custom Widgets Accessible with ARIA
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.

17 min. read role · aria-expanded · aria-activedescendant · keyboard NVDA · VoiceOver · JAWS · Alpine.js

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.

11. FAQ: Making Custom Widgets Accessible with ARIA

1When should I use a native HTML element instead of a custom widget?
Whenever select, input list, or button can cover the desired behavior and design. Native elements come with keyboard handling, focus management, and screen reader semantics for free.
2What is the WAI-ARIA Authoring Practices Guide (APG)?
A catalog of tested interaction patterns maintained by the W3C working group for combobox, tabs, dialog, and more, with exact requirements for roles, states, and keyboard handling.
3What exactly does aria-expanded mean?
Indicates whether a collapsible element is currently visible or hidden. Must be set exactly in sync with the actual CSS visibility state.
4What is aria-activedescendant used for?
Points by ID to the currently highlighted child element, while DOM focus stays on the input field. Screen readers announce the active option without the real focus ever moving.
5Roving tabindex or aria-activedescendant, what is the difference?
Roving tabindex moves the real DOM focus. aria-activedescendant only moves an ARIA reference, focus stays on the container. For editable comboboxes, aria-activedescendant is correct.
6Which keys must a combobox support at minimum?
Arrow down/up for navigation, Enter to select, Escape to close, Home and End for the start and end of the list. Typeahead further improves usability.
7Is automated testing with axe-core enough?
No. Automated tools only detect static rule violations. Whether announcements are understandable and keyboard handling works only shows up in a real screen reader test.
8Which screen readers should I use for testing?
At least NVDA with Firefox and VoiceOver with Safari, ideally complemented by JAWS with Chrome, since JAWS is still widely used in many enterprise environments.
9What is the most common mistake in homegrown dropdowns?
An input field with no visible label, where only placeholder text serves as the label. Placeholders disappear while typing and are often not read out.
10Do I need aria-label even when a visible label already exists?
With a correctly linked label element, an additional aria-label is usually redundant and can create conflicts. aria-labelledby is worthwhile for multi-part labels.