Your combobox passes the audit. It still doesn't work with a screen reader.
I've been building frontends for 13 years, and I've used a lot of open source along the way. This year I wanted to give something back. So I decided my first contribution would be small, and I'd do it properly.
I picked a combobox. It looks like the simplest component there is: an input, a list, pick one. It isn't.
Most component libraries treat accessibility as a checklist. Add role="listbox", add aria-expanded, run an automated audit, get a green tick, ship. And the result can still be unusable with a screen reader. The hard parts of a combobox aren't attributes. They're about focus, what gets announced and when, and what happens when the list changes while someone's typing.
This post goes through the decisions I made building ngx-generic-combobox, an Angular 22 component built on Signals. Most of them apply to any framework.
Arrow keys, Escape, filtering and Enter. The panel on the right shows what a screen reader hears.
1. Focus never leaves the input
This is the one most implementations get wrong.
When a user arrows through the options, the obvious move is to put real DOM focus on each option. Screen readers are happy. But now the user can't type, because focus is no longer in the input. And typing is the whole point of a combobox.
The fix is virtual focus. DOM focus stays in the input, and aria-activedescendant tells assistive technology which option is active:
<input
type="text"
role="combobox"
[attr.aria-expanded]="isOpen()"
[attr.aria-controls]="listboxId"
[attr.aria-activedescendant]="activeDescendantId()"
aria-autocomplete="list"
/>
Note that role="combobox" sits on the input itself, not on a wrapper div. ARIA 1.2 moved it there so the element that has focus is the element carrying the state.
There's a detail here that's easy to miss. When nothing is active, aria-activedescendant should be absent, not an empty string:
protected readonly activeDescendantId = computed(() => {
const i = this.activeIndex();
return this.isOpen() && i >= 0 ? this.optionId(i) : null;
});
Returning null makes Angular remove the attribute. An empty string is a reference to nothing, and some screen readers react to that by announcing nothing at all.
Virtual focus has a side effect I only found while writing a Storybook story with a long list. Because DOM focus never moves into the list, the browser never scrolls the active option into view. Arrow down far enough and you're selecting options you can't see. So an effect handles it:
effect(() => {
const index = this.activeIndex();
if (!this.isOpen() || index < 0) return;
const option = this.listbox()?.nativeElement.children[index] as HTMLElement | undefined;
option?.scrollIntoView?.({ block: 'nearest' });
});
block: 'nearest' scrolls the minimum needed, and does nothing when the option is already visible. Without it, the list jumps on every keypress.
2. Announce the count, not the list
When you type into a combobox, the list filters. A sighted user sees that happen. A screen reader user gets silence.
So there's a separate live region that announces how many results are left:
<div class="ngx-combobox__sr-only" role="status" aria-live="polite">{{ announcement() }}</div>
protected readonly announcement = computed(() => {
if (!this.isOpen()) return '';
const n = this.filtered().length;
if (n === 0) return this.emptyMessage();
return n === 1 ? '1 result available' : `${n} results available`;
});
It's polite on purpose. Reading the whole list out would interrupt the user mid-word. The count is enough to know whether to keep typing.
And the "No results" row in the list is deliberately not role="option". You can't select it, so it shouldn't be counted as an option.
3. Small keyboard decisions that matter
None of these is big on its own. Together they're the difference between a component that works and one that's just technically compliant.
Escape does two different things. The first press closes the list and keeps what you typed. Only a second press, with the list already closed, clears the field. If one press did both, a single slip of the finger would wipe out someone's input.
case 'Escape':
event.preventDefault();
if (this.isOpen()) {
this.close();
} else {
this.query.set('');
this.value.set(undefined);
}
break;
Navigation stops at the ends. No wrapping from the last option back to the first. In a filtered list, wrapping makes it really easy to go past the option you wanted without noticing.
Disabled options stay in the DOM. Arrow keys skip them and you can't select them, but they're still rendered and counted. If the number of options changed depending on how you got there, the announced count would stop making sense.
Home and End belong to the text cursor while the list is closed. They only jump between options once it's open. I know this one well, because my first test run failed on it. The component was right. The test was wrong.
Blur restores the selected label. If you type half a word and tab away, the field goes back to what you actually picked. A half-typed query never pretends to be a choice.
Tab never gets trapped. It closes the list and moves on, and doesn't quietly select anything.
4. Visual accessibility too
Screen readers aren't the whole story.
The active option has a background colour and an inset border, because colour alone fails WCAG 1.4.1. Windows High Contrast mode drops backgrounds entirely, so there's a forced-colors block that swaps in a system-coloured border:
@media (forced-colors: active) {
.ngx-combobox__option--active {
border: 2px solid Highlight;
}
}
And prefers-reduced-motion turns off the chevron animation.
5. Test the contract, not the implementation
The component has 20 tests, and they check the accessibility contract: the label is associated, aria-expanded is right, aria-activedescendant is there when it should be and gone when it shouldn't, only one option is aria-selected, the live region says the right thing, and Escape behaves both ways.
Here's why that matters. A test that says "clicking an option sets the value" will keep passing after a refactor that breaks the component for screen reader users. A test that says "aria-activedescendant points at the active option" won't.
I've led frontend teams for years, and this is the same rule I'd set for any test suite: test what the user depends on, not how the code happens to do it.
What isn't done yet
I'd rather be honest about this than oversell it.
- It hasn't been tested with real screen readers yet. The tests check the ARIA contract, which is necessary but not enough. NVDA, JAWS and VoiceOver disagree with each other in ways only manual testing finds. That's next.
- No automated axe check in CI yet.
- No multi-select, option groups or async loading. Each of those changes the ARIA pattern rather than just adding to it, so I'd rather build them properly than fake them.
Try it
npm install ngx-generic-combobox
The source is on GitHub, and every decision in this post has a comment in the code explaining why.
If you use a screen reader and try it, I'd really like to hear what breaks.

Top comments (0)