A native <select> element is accessible by default — keyboard navigation, screen reader announcements, and focus handling all come free. The moment a design calls for a custom-styled dropdown that a native select can't achieve (multi-line options, icons, custom positioning), all of that accessibility has to be rebuilt by hand, and it's genuinely easy to build something that looks right and works fine with a mouse while being unusable with a keyboard or a screen reader.
Starting point: the markup shape
<div class="select-wrapper">
<button
type="button"
id="select-trigger"
aria-haspopup="listbox"
aria-expanded="false"
aria-labelledby="select-label select-trigger"
>
Choose a plan
</button>
<ul role="listbox" aria-labelledby="select-label" id="select-listbox" hidden>
<li role="option" id="opt-1" tabindex="-1">Starter</li>
<li role="option" id="opt-2" tabindex="-1">Pro</li>
<li role="option" id="opt-3" tabindex="-1">Enterprise</li>
</ul>
</div>
role="listbox" and role="option" tell assistive technology what these otherwise-generic <ul>/<li> elements actually represent — without them, a screen reader has no way to know this is a selection widget rather than an ordinary bulleted list, regardless of how it's visually styled.
Keyboard support: the part that's easy to skip entirely
A dropdown that only opens and closes on click is unusable for anyone navigating by keyboard, which includes not just screen reader users but a meaningful number of sighted users who simply prefer or need keyboard navigation. The full expected keyboard behavior for a listbox pattern:
- Enter/Space on the trigger opens the list and moves focus to the first (or currently selected) option.
- Arrow Down/Up moves focus between options without closing the list.
- Enter or Space on a focused option selects it and closes the list, returning focus to the trigger.
- Escape closes the list without changing the selection, returning focus to the trigger.
- Typing a letter jumps focus to the next option starting with that letter — a native select does this automatically, and it's easy to forget entirely when rebuilding one from scratch, until a user relying on it notices its absence immediately.
listbox.addEventListener('keydown', (e) => {
const options = [...listbox.querySelectorAll('[role="option"]')];
const currentIndex = options.indexOf(document.activeElement);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
options[Math.min(currentIndex + 1, options.length - 1)].focus();
break;
case 'ArrowUp':
e.preventDefault();
options[Math.max(currentIndex - 1, 0)].focus();
break;
case 'Enter':
case ' ':
e.preventDefault();
selectOption(options[currentIndex]);
closeListbox();
trigger.focus();
break;
case 'Escape':
closeListbox();
trigger.focus();
break;
}
});
Every option has tabindex="-1" rather than being individually tab-focusable — the listbox manages focus internally via JavaScript, moving actual DOM focus to the relevant option element on each arrow key press, rather than relying on the browser's default tab order to move between options one at a time.
The ARIA mistake a screen reader test actually caught
The first version selected an option using aria-selected="true" on the chosen <li>, updated correctly in the DOM — and testing with VoiceOver revealed it wasn't being announced at all when navigating between options with arrow keys. The actual bug: aria-activedescendant was missing from the trigger/listbox container entirely. Moving DOM focus to an option element visually and functionally worked, but without aria-activedescendant telling the screen reader which option is the "active" one relative to where the user's actual attention is, VoiceOver kept announcing the container's role and label repeatedly rather than announcing each option as focus moved between them.
<!-- Missing this meant VoiceOver never announced which option had focus -->
<ul
role="listbox"
id="select-listbox"
aria-activedescendant="opt-2"
>
...
</ul>
function focusOption(option) {
option.focus();
listbox.setAttribute('aria-activedescendant', option.id);
options.forEach((opt) => opt.setAttribute('aria-selected', opt === option));
}
Updating aria-activedescendant on the container every time focus moves between options — in addition to, not instead of, moving actual DOM focus — is what makes a screen reader correctly announce each option as the user arrows through the list. This is a genuinely easy thing to miss when testing only with a mouse and eyes, because the visual behavior looks completely correct without it; the gap only shows up with an actual screen reader running, which is exactly why testing with one, not just reasoning about ARIA attributes from documentation, caught it here.
Managing focus when the list closes
A second, smaller gap: closing the list — whether via Escape, selecting an option, or clicking outside — needs to explicitly return focus to the trigger button. Without this, focus can end up lost on a removed or hidden element, which for a screen reader user means suddenly having no clear sense of where they are on the page at all, a genuinely disorienting experience that's easy for a sighted developer testing with a mouse to never notice.
function closeListbox() {
listbox.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus(); // explicit — never assume focus survives on its own
}
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target) && !listbox.hidden) {
closeListbox();
}
});
Labeling correctly for both visual and non-visual users
The trigger button's accessible name needs to reflect the current selection, not just a static "Choose a plan" label, once something has actually been selected — otherwise a screen reader user tabbing past the dropdown after making a selection hears the original placeholder text rather than confirmation of what they chose.
function selectOption(option) {
trigger.textContent = option.textContent; // update visible label
trigger.setAttribute('aria-label', `Selected plan: ${option.textContent}`);
selectedValue = option.dataset.value;
}
What we'd tell someone building this pattern for the first time
- Default to a native
<select>whenever the design allows it. Every line of code in this post exists to rebuild behavior a native element provides for free — only take on that cost when the design genuinely requires something a native select can't do. - Test with an actual screen reader, not just ARIA attribute correctness reasoned about on paper. The
aria-activedescendantgap here looked completely fine in code review and only surfaced under real VoiceOver testing. - Focus management on close is easy to forget and genuinely disorienting for keyboard and screen reader users when it's missing — treat it as a required step, not a nice-to-have.
If your team is auditing other components for gaps like the ones found here, five accessibility fixes teams miss most often covers a broader checklist beyond just this one component pattern, and if you're deciding how much custom component work is worth the accessibility rebuild cost at all, that's worth weighing against Next.js vs Laravel + Blade if the underlying stack decision is still open — some component libraries handle a meaningful share of this accessibility work for you, which is a real factor in that broader choice.
Typeahead: the detail a native select gets right that's easy to skip
The keyboard behavior list above mentioned typing a letter to jump to a matching option, and it's worth showing the actual implementation, because a naive version only handles the first keystroke correctly and breaks on a second consecutive letter — typing "en" to reach "Enterprise" needs to accumulate recent keystrokes into a short buffer, not just match against the single most recent character:
let typeaheadBuffer = '';
let typeaheadTimer = null;
listbox.addEventListener('keydown', (e) => {
if (e.key.length === 1 && /[a-z0-9]/i.test(e.key)) {
typeaheadBuffer += e.key.toLowerCase();
clearTimeout(typeaheadTimer);
typeaheadTimer = setTimeout(() => { typeaheadBuffer = ''; }, 500);
const match = options.find((opt) =>
opt.textContent.toLowerCase().startsWith(typeaheadBuffer)
);
if (match) focusOption(match);
}
});
Clearing the buffer after 500 milliseconds of no further typing is what lets the same key press both "type ahead to a match" and, on a fresh press after a pause, start a new search rather than endlessly appending to a stale buffer — pressing "p" then waiting then pressing "p" again should jump between options starting with "p," not try to match "pp." This exact timing detail is invisible in a quick demo and immediately noticeable to anyone who actually relies on typeahead as their primary way of using a dropdown.
Testing this without a physical screen reader device
Not every developer has easy access to a screen reader for regular testing, and the honest answer is that browser-based approximations are a real but imperfect substitute. macOS ships VoiceOver built in (Cmd+F5 to toggle), and Windows has NVDA available as a free download — both are worth having installed specifically for testing custom components like this one, rather than reasoning about ARIA correctness purely from documentation. A quick, repeatable manual test sequence that catches most of the gaps this post covers:
- Tab to the component using only the keyboard, confirm the trigger receives visible focus and its label is announced correctly.
- Open it and arrow through every option, confirming each one is announced by name as focus moves, not just the container's role repeated on every keystroke.
- Select an option and Tab away, confirming the trigger's accessible name now reflects the selection rather than the original placeholder text.
- Open it, press Escape, and confirm focus lands back on the trigger, not lost somewhere else on the page.
Running through this sequence takes a few minutes and would have caught both gaps described above — the missing aria-activedescendant and the missing focus-return-on-close — well before either shipped, which is the actual argument for building this kind of manual check into a component's definition of done rather than treating accessibility testing as a separate, occasional audit disconnected from normal development, where it's easy for a check like this to quietly fall off the list once a deadline is close and the component already looks and works correctly to everyone testing it with a mouse rather than a keyboard or a screen reader.
No comments yet.
Be the first visitor to add a thoughtful comment on this article.