Accessibility · ARIA Practice
The Combobox Popup Contract: Ownership, Focus, and Announcement
What a text input and its suggestion list must agree on to be one widget: the popup relationship, where DOM focus lives during typing, and who announces the active option.
An autocomplete input is two elements that must behave as one: a text field that owns the caret and a list that displays candidates. The input has focus; the list has the user’s attention. Every question about the pattern, whether it is which element carries role="combobox", whether the popup is a child or a sibling, what happens to the caret when an option is highlighted, or whether a screen reader announces the option or the typed text, reduces to one problem: the accessibility tree has a single focus pointer, and the widget needs to express two positions with it.
Why the input owns the role
The element with role="combobox" is the element that holds focus and the caret, which is the text input. The popup is a separate element with role="listbox", referenced from the input.
This is not the only historical arrangement. An earlier version of the ARIA specification described the combobox role as belonging to a container that wrapped both the input and the popup, with the input as a child. Implementations shipped against that model, and the difference is not cosmetic: with the role on the wrapper, the wrapper is the widget in the tree, and the input is one of its parts; with the role on the input, the input is the widget and the popup is its owned content. Current WAI-ARIA 1.2 puts the role on the input, and the accessibility API mappings follow that. Code written against the older model still runs and still announces something, which is why the two arrangements coexist and why documentation on the pattern is inconsistent.
The practical test is which element the screen reader reports as the focused widget when the user tabs in. In the current model it reports a combobox whose name is the input’s label and whose value is the input’s text. In the older model it reports a combobox containing an edit field, which produces a nested announcement and a different set of keys.
The popup relationship is not aria-owns
The input must tell the tree that the listbox belongs to it. Three attributes are candidates, and only one combination is current.
aria-controls names the element the input controls. It is the attribute that expresses the relationship in WAI-ARIA 1.2’s combobox definition, and it survives the popup being outside the input’s DOM subtree.
aria-expanded on the input reports whether the popup is displayed. The value must be true when the list is open and false when it is not, and the attribute must be present rather than omitted, because a combobox that never reports expansion is not distinguishable from one that has no popup.
aria-haspopup="listbox" declares the type of popup. For a combobox whose popup is a listbox, the specification makes listbox the implicit value of aria-haspopup on the combobox role, so writing it is redundant but harmless. Writing aria-haspopup="true" is equivalent to menu, which is the wrong popup type for a suggestion list and changes how the popup is described.
aria-owns is the one that must not be used for the ordinary case. It reparents the popup into the input in the accessibility tree, and the reason to avoid it is that tree order no longer matches DOM order: a screen reader reading the document encounters the listbox immediately after the input rather than wherever the popup sits in the layout, and the reading order of surrounding content changes. The attribute is for repairing trees that cannot be repaired in markup, not for declaring a relationship that aria-controls already declares. The wider costs of reparenting are covered in the role attribute as a contract with the accessibility tree.
Where the active option lives
The popup’s options must be navigable with arrow keys while the caret stays in the input, so the input keeps DOM focus and points at the active option with aria-activedescendant. The mechanism, and its comparison with roving focus, is set out in roving tabindex and aria-activedescendant as competing models.
<label for="city">City</label>
<input id="city" role="combobox" aria-expanded="true"
aria-controls="city-list" aria-autocomplete="list"
aria-activedescendant="city-3" autocomplete="off">
<ul id="city-list" role="listbox" aria-label="Cities">
<li role="option" id="city-3" aria-selected="true">Lisbon</li>
</ul>
Three details in that markup carry weight.
aria-autocomplete distinguishes the behaviours. list means the popup shows completions of the typed text and nothing is completed inline. both means the input also completes inline, with the completed portion selected so that continued typing replaces it. inline means completion happens only in the input, with no popup. The value matters to assistive technology because it determines what the reader says about the suggestions and whether it expects a selection in the field.
aria-selected on the active option is what makes the position audible. A reader following the activedescendant announces the option’s name and its selected state; without the attribute, the announcement is the name alone and the user cannot tell whether arrowing has changed the value or merely previewed it. The distinction between preview and commit is the widget’s own design decision, but the tree must reflect whichever it is.
The autocomplete="off" on the input is not an accessibility attribute, and it is not always honoured by browsers, which may still offer their own saved-value suggestions over the custom popup. When the browser’s list appears alongside the custom one, the user has two popups with different key handling. Suppressing the browser’s list is unreliable; the workable design is to accept that both may exist and to make the custom popup’s open state depend on the input’s own filtering rather than on every keystroke.
The keyboard contract
The pattern assigns specific keys, and they conflict with the browser’s defaults, so each has to be handled explicitly.
Arrow Down and Arrow Up move the active option without moving the caret. When the popup is closed, Arrow Down typically opens it. Enter commits the active option; if no option is active, Enter submits the form, which is the behaviour users expect from a text field. Escape closes the popup without clearing the input, and a second Escape may clear it. Tab leaves the widget, and the question of whether leaving commits the active option is a design decision, since committing on blur is common and surprises users who tab away to cancel.
Home and End move the caret to the start and end of the input, not the first and last option. That is a deliberate difference from a standalone listbox, and it follows from the caret being in the input. Some implementations additionally use Alt+Arrow Down to open the popup without filtering, which is a convention rather than a requirement.
Two of these keys need care in event handling. Arrow keys inside the input must not scroll the page, so the handler calls preventDefault on the ones it consumes. And the composition guard is mandatory: while an IME is composing, arrow keys belong to the candidate window, so the handler returns early when event.isComposing is true. Without the guard, a user typing Japanese or Chinese cannot select a candidate with the arrow keys, because the widget steals them.
Announcement, and the double-speak failure
The active option is announced through the activedescendant relationship. The input’s own value change is announced by the input, because the input is focused and its value is changing. These are two different announcements for what the user perceives as one interaction, and the ordering is not guaranteed to be pleasant: a reader may speak the typed character, then the option name, then the result count, in that order or another.
The failure that matters is the one where the widget also writes the active option’s text into a live region. The option is already announced by virtue of being the active descendant; a live region repeating it produces the name twice, once from each channel. The rule from how live regions turn DOM mutations into speech applies: one state change, one channel. The result count, as in “8 results available”, is genuinely new information that no other channel reports, so it is the one thing worth a polite region, and it should be updated at a throttled rate rather than on every keystroke.
Failures at the boundaries
The popup that never reports expansion. A combobox whose aria-expanded stays false while the list is visible is a widget that claims to be closed; users of assistive technology are told there are no suggestions while the screen shows eight.
The popup rendered outside the widget’s DOM subtree, typically in a portal at the end of the document. aria-controls still points at it, so the relationship holds, but the visual order and the tree order diverge: the listbox appears after all other content in the tree. Sequential reading reaches it in the wrong place. Portalling is common in component frameworks, and the mitigation is to keep the popup adjacent in the tree when possible, or to accept the divergence and ensure nothing in between is meaningfully ordered.
The popup that closes on focusout. Moving focus is not part of this pattern, so the handler should not fire at all, but a portalled popup that contains a scrollbar can take focus when clicked, and a naive focusout handler closes the list under the pointer. Checking whether the new focus target is inside the popup before closing is the fix.
The stale activedescendant. The input points at an option ID that the last filter removed, and the reader falls back to announcing the input alone. Writing the attribute only after the list has rendered, and clearing it to no value when the list is empty, keeps the reference valid.