Lesson 11 of 30
A native <select> gets keyboard behavior, screen reader announcement, and mobile picker integration for free. The moment you build a custom autocomplete instead, all of that becomes your job.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
Question 1 of 3
In a custom combobox built as a text input plus a filtered dropdown list, where should keyboard focus stay while the user presses the down arrow to move through options?
Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
A native <select> element is accessible by default: arrow keys move through options, typing a letter jumps to a matching one, screen readers announce it correctly, and mobile browsers swap in the OS's own picker UI. None of that is written by the app — the browser does it.
The catch is that a native <select> can't do one very common thing: let a user type freeform text and filter a list of options as they go (an address autocomplete, a "search for a user" field, a tag picker). The moment a product needs that interaction, the native element is off the table, and a custom widget — a text input paired with a filtered dropdown list — has to be built by hand. That hand-built widget doesn't inherit any of the native element's free behavior. Every piece of it — what gets announced, what arrow keys do, what Enter does — is now the app's responsibility to reimplement correctly.
This is why a combobox is one of the genuinely hardest interactive widgets to get right: it's not one behavior, it's an entire small state machine (open/closed, which option is highlighted, what the current filter text is) that has to stay in sync between the visuals and what a screen reader announces.
<label for="city-input">City</label>
<input
id="city-input"
role="combobox"
aria-expanded="true"
aria-controls="city-listbox"
aria-activedescendant="city-option-2"
autocomplete="off"
/>
<ul id="city-listbox" role="listbox">
<li id="city-option-1" role="option">Austin</li>
<li id="city-option-2" role="option" aria-selected="true">Auckland</li>
<li id="city-option-3" role="option">Amsterdam</li>
</ul>
The user types into the input. Focus is on the text field — it never leaves.
Four pieces do the real work here. role="combobox" on the input tells assistive technology this text field controls a popup. aria-expanded reflects whether the listbox is currently open — false when collapsed, true when the list is showing. aria-controls points at the listbox's id, wiring the two elements together. And aria-activedescendant is the piece people most often get wrong: as the user presses the down arrow to move through filtered options, keyboard focus never leaves the text input — it stays there the whole time, because moving real focus into the list would break the ability to keep typing. Instead, aria-activedescendant is updated to point at whichever option id is currently "highlighted," and the screen reader announces that option as if focus had moved there, even though it didn't. Arrow keys update which option is highlighted (and which id aria-activedescendant points to); Enter commits the currently highlighted option as the selected value.
aria-activedescendant changes to signal which option is "active."aria-expanded has to be kept in sync with the real open/closed state, not just set once. A stale aria-expanded="true" on a closed listbox tells a screen reader user there's an open list that isn't actually there.<div>-based dropdown with onClick handlers and no role="combobox" gives a screen reader user nothing — no announcement that this is an autocomplete field, no indication a list is open, no way to know what's highlighted.aria-controls alone doesn't announce anything by itself — it's a structural relationship, not an announcement. The activedescendant and expanded state are what actually communicate the live interaction.<select> (or a <select> with <optgroup>) would actually satisfy the requirement is a common over-build; the type-to-filter interaction is the only real reason to leave native behavior behind."Walk me through how you'd make a custom autocomplete accessible" is a real, common senior-level question, and a strong answer names aria-activedescendant specifically and explains why focus has to stay on the input — a shallow answer just says "add ARIA roles" without the mechanism.