<ui-combobox>

ui-combobox.mjs · Full test suite

APG combobox-with-list-autocomplete pattern. One tag with four sub-variants selected via mode="none|list|inline|both" (the host attribute is mode, not autocomplete, so it can't collide with the HTML autocomplete autofill token on the inner input). The consumer passes an async source function; the component owns the popover, ARIA, keyboard nav, the ghost-text mirror, and Google-style arrow-nav preview (arrow keys swap the input value to the highlighted item; wrap past either end restores the typed query). The chips / multiple attributes add the tag-input pattern (Open UI "Tag", Material "input chips"): committed values render as removable chips and the typed text is only ever a query — see §11–12.

In its single-line modes the field draws at the same height as a text <input> beside it, so a form column keeps one rhythm. The component sets line-height: normal on the inner input and its ghost-text mirror to reach that height; the contract is on Form fields § Field height. Chip rows wrap and grow past it by design.

AttributeValuesDefaultPurpose
modenone · list · inline · bothlistAPG sub-variant. Distinct from the inner input's HTML autocomplete attribute (browser autofill token) — set that on the child input separately.
namestring—Form field name (participates via hidden input). Omit if your child <input> carries its own name.
valuestring""Initial value
min-charsint0 (2 for inline)Skip source call below N chars
debounce-msint150Debounce before calling source
disabledboolean—Disables input
chipsboolean— Single-selection chips mode. The selected item appears as one removable chip. Only listed items can be selected; typing a new query replaces the chip. Supports mode="list" and "none". With inline or both, it uses list and logs a warning because inline text completion does not support chip selection (§11). Set this attribute before connection; changing it while chips are present is unsupported.
multipleboolean— Enables chips with multiple selections. Selected items are hidden from the option list and return when their chips are removed. With min-chars="0", the list stays open after each selection.
rawboolean— Remove the field's border, padding and radius so a wrapper such as .input-group can provide them. The dropdown retains its border and shadow.
anchorelement id—Anchor and size the dropdown to the element with this id (typically the wrapping .input-group) instead of to the combobox itself.
PropertyTypePurpose
.sourceasync ({query, req_id, signal}) => items[]Required. Return items, or throw to signal an error. On abort, reject with new DOMException('aborted', 'AbortError'). Promises are memoized per query, so keep source() pure — external UI state belongs in pipeline-event listeners.
.render_item(item, query) => string | NodeCustom option body. Strings are inserted as textContent (escaped); return a Node for rich markup.
.format(item) => stringCanonical string used for input value on commit and for the inline ghost suffix.
.to_query(input) => stringTyped text → the canonical query handed to source() and used as the memo key. Default is identity; override for diacritic normalization, alias substitution, or language-specific casing. Dispatched events still carry the literal typed text, so consumers see what the user typed.
.parse(item) => anyEach item returned by source() passes through this before format, render_item, and the selection see it. Default is identity; override to adapt a server shape ({key} vs {id} vs a scalar) into the one the other callbacks expect.
.option_disabled(item) => boolean Return true to display the item as disabled, with aria-disabled="true". It cannot be highlighted, selected or previewed by the inline suggestion. Keep it visible when render_item explains why it is unavailable; filter it out in source() to hide it. This callback runs on every render, so availability can change.
.selectedItem | nullFirst committed item (null = free-form / nothing selected).
.selected_itemsItem[]All committed item objects (0–1 entries outside multiple).
.valuesstring[]Committed labels — format(item) per selection entry.
.valuestringCurrent input value. Chips modes: the committed label (first) or "" — the typed text is a query, not a value. Setting "" clears the selection silently; a non-empty string becomes a single string-item chip (form-restore path, no source round-trip).
.openbooleanWhether listbox is open.
.empty_labelstringText shown when source() returns []. Default "No matches".
.chip_remove_label(label) => stringChip remove-button accessible name. Default `Remove ${label}` — override for localization (like empty_label).
.chip_announcement(action, label) => stringPolite live-region text on chip add/remove. Default `${label} added|removed` — override for localization.
EventDetailFires
input—Whenever the visible value changes due to user action: typing, arrow-nav, wrap/Escape restore, ghost-accept, commit, .clear(). Read event.target.value. Programmatic .value sets from outside the component do not fire.
change{value, item, source, values, action?}On commit (click, Enter, Tab, Right, End, clear). Suppressed when value+item are unchanged. When commit changes the visible value, input fires first, then change. Chips modes add action: 'add' | 'remove' | 'clear'; on remove, item is the removed item and value/values are the post-removal state.
ui-combobox-highlight{item, opt_id, value}Low-level hook: fires on every arrow-nav or hover highlight change (including wrap back to typed — item/opt_id are null). Use when you need to react to virtual-focus changes that don't alter the input value (e.g. mode="list" or "none", mouse hover). For most consumers, input + change is enough.
ui-combobox-open{}Listbox opened.
ui-combobox-close{reason}Listbox closed.
ui-combobox-fetch{query, req_id, from_memo}A fetch is starting. Fires for memoized queries too (from_memo: true), so status indicators aren't stuck on stale state when source()'s body is skipped.
ui-combobox-response{query, req_id, items, from_memo}Results settled and rendered. The component has already dropped stale responses, so listeners always see the latest.
ui-combobox-error{query, req_id, error}Source rejected with a non-AbortError. Aborts are swallowed silently.
ui-combobox-revert{reason, query}Search was abandoned without a response: input dropped below min-chars (reason: "min-chars") or .clear() was called (reason: "clear"). Use to reset external status UI to idle.
MethodSignaturePurpose
open_list()() → voidProgrammatically open the listbox (fires a source call if needed). No-op when already open, disabled, or in mode="inline" — that mode has no list.
close()() → voidProgrammatically close the listbox. Fires ui-combobox-close with reason: "programmatic".
clear()() → voidReset value, selection (including all chips), and ghost; close the list. Fires input, ui-combobox-revert, and change (chips modes: action: 'clear').
select(item)(any) → booleanChips modes: commit an item programmatically through the same flow as clicking its option. false when already selected. (Named select/deselect because Element.prototype.remove is taken.)
deselect(item_or_label)(any) → booleanRemove a chip by item object or label; fires change with action: 'remove'. false when nothing matched.
focus(options?)(FocusOptions?) → voidForward focus to the inner <input>. Safe to call before connect (no-op).
revert()() → voidDrop tentative state (arrow-nav baseline swap and inline ghost) without committing or clearing. For Cancel buttons and route changes that want to settle the UI without firing a change.
commit_value(v)(string) → voidUser-driven equivalent of el.value = v: assigns the value AND triggers the same change/refetch path the input-event would. Use when a programmatic caller wants consumers (search wiring, revert listeners) to react.
CSS custom propertyDefaultDescription
--_input-bgvar(--surface-1, #fff)Input field background color.
--_input-inkvar(--text-1, #111)Input field text color.
--_input-bordervar(--border-color, #ccc)Input field border color.
--_ghost-inkvar(--text-2, #777)Inline ghost-suffix text color.
--_list-bgvar(--surface-overlay, #fff)Dropdown listbox background color. The list floats in the top layer, so it takes the overlay surface rather than a step on the surface ramp — see Elevation § Overlay surfaces.
--_list-bordervar(--border-color, #ccc)Dropdown listbox border color.
--_list-max-height16remMaximum height of the dropdown listbox before it scrolls.
--_list-border-widthvar(--border-size-1, 1px)Dropdown listbox border width.
--_list-radiusvar(--radius-2, 4px)Dropdown listbox border radius.
--_option-hover-bgvar(--surface-3, #eee) Background for hovered and highlighted options (aria-selected="true"). Pointer and keyboard navigation use the same color.
--_option-selected-bgvar(--surface-3, #eee)Option background when selected (committed).
--_option-mark-bgtransparentBackground of a <mark> inside an option — the query-match highlight a consumer's render_item can emit. Marks are bolded; the tint is off by default.
--_option-mark-inkinheritText color of that <mark>.
--_option-pad-yvar(--size-2, 0.5em) Vertical padding for option, empty, error and loading rows. This is independent of --_field-pad-y, which chips mode sets to zero for the input. Dropdown rows still need their padding.
--_option-pad-xvar(--size-2, 0.5em)Horizontal padding of those same rows. Square inset by default, matching the field.
--_field-pad-yvar(--size-2, 0.5em)Vertical padding for both the input and the ghost mirror (kept in sync).
--_field-pad-xvar(--size-2, 0.5em)Horizontal padding shorthand; overridden by --_field-pad-left and --_field-pad-right. Same token as the vertical padding — a field's inset is square (Forms § Anatomy).
--_field-pad-leftvar(--_field-pad-x)Left padding for the input and mirror — set independently when a prefix decoration (e.g. a flag) needs extra room.
--_field-pad-rightvar(--_field-pad-x)Right padding for the input and mirror — set independently when a suffix decoration needs extra room.
--_field-border-widthvar(--border-size-1, 1px)Input field border width (also applied to the invisible mirror border so box models stay in sync).
--_field-radiusvar(--radius-2, 4px)Input field border radius.
--_chip-bgvar(--surface-3, #eee)Chip background (chips modes).
--_chip-inkvar(--text-1, #111)Chip text color.
--_chip-bordertransparentChip border color — set for an outline-style chip.

The dropdown can grow wider than its field. It uses min-width: anchor-size(width), allowing rich options (§8) to determine a larger width. To keep it within a dialog or narrow panel, set [role="listbox"] { max-width: anchor-size(width) }. This limits the list to the field width so option rows can truncate overflowing text.

The component reflects two state attributes on its host. Use them in CSS, but do not set them yourself. data-loading="true" appears after a source() call has been pending for 300 ms; the default styling dims existing options. Faster responses show no loading state. data-ghost="active" indicates a visible inline suggestion. It does not change the typed characters or casing; committing applies format(item).

Consumers updating external state (status badges, suffix icons) should listen to the pipeline events above rather than writing state inside source(). The component memoizes results per query, so source()'s body does not re-run on a repeat query — writes placed there would leave the indicator stuck. See §9 below for a worked example.

Supply your own <input> as a child to control native input semantics: type, autocomplete (browser autofill token), name, required, placeholder, pattern, maxlength, minlength, mobile hints like inputmode. The combobox owns the ARIA contract (role, aria-expanded, aria-controls, aria-autocomplete, aria-activedescendant) and the listbox popover. If you don't supply an input, the component creates a plain <input type="text" autocomplete="off">; that's fine for app-only pickers (country, role) but not for fields the browser can autofill like email or username — see §6.

Each demo's Show code contains the complete HTML, CSS, and JS for that demo — copy and paste works as-is. The demo area is populated by cloning the template at the top of each section.

1. mode="list" — filtered listbox

The default. Typing filters a listbox of suggestions. Free-form values are allowed on Enter.

Show code

HTML

JavaScript

2. mode="inline" — ghost suffix only

No listbox. The top match is shown as a gray suffix after the caret; Tab or Right accepts it.

Show code

HTML

JavaScript

3. mode="both" — listbox + ghost + arrow-nav preview

The full APG pattern. Type to filter; arrow keys preview each match in the input (Google-style); wrap past the last/first item restores your typed query. Enter/Tab commits.

Show code

HTML

JavaScript

4. mode="none" — editable select

Always shows the complete, unfiltered list. Use this for a free-text field with suggested values. Focusing the field opens the list without typing.

Show code

HTML

JavaScript

5. Async source with simulated latency

Shorter queries deliberately take longer in this demo, simulating a broad search such as s taking longer than spock. Normal typing can therefore produce responses out of order. This exercises stale-response checks, AbortSignal cancellation and the rule that existing results remain visible until replacements arrive. The host's data-loading="true" attribute dims existing options and can be used for additional loading styles.

Show code

HTML

JavaScript

6. Custom render_item — email autocomplete (with browser autofill)

Each option shows a name above an email address, as used by the workflow invite form. The child <input type="email" autocomplete="email"> retains browser email autofill while the combobox supplies team-member suggestions. The combobox suppresses its empty-state row when there is no query, reducing interference with the browser's autofill popup.

Show code

HTML

JavaScript

7. Error handling

Type a query containing "fail" to see the error row. The next successful query clears it.

Show code

HTML

JavaScript

8. Rich decorated items + prefix decoration

Consumer-controlled decoration: the listbox renders each option as flag + name + dialing code via .render_item, and a flag prefix inside the group tracks both arrow-nav preview and committed selection. The wrapper is the shared .input-group pattern, and the raw attribute strips the combobox's own chrome so the group owns the border and focus ring.

Two hooks are wired:

Related: the Open UI customizable select rich-content example and <x-select> show the same pattern for a closed select.

Show code

HTML + CSS

JavaScript

9. Suffix search indicator

Async source with a live-region suffix that shows a spinner while loading and a result-state icon afterwards. The group drives state via group.dataset.state, which the shared .input-group state styles pick up (flux → warning tint while loading; the .suffix.state slot recolors to match). The combobox carries raw so the group owns the border.

Keep source() limited to returning items or throwing an error. Update the suffix in listeners for ui-combobox-fetch, ui-combobox-response, ui-combobox-error and ui-combobox-revert. These events also cover cached queries and track the latest request. Each handler checks cb.value so a late response cannot restore an indicator after the input was cleared. An input listener handles an empty field immediately; clear() emits ui-combobox-revert itself.

Show code

HTML

JavaScript

10. Form participation

The name attribute creates a hidden field; the value is sent with the form on submit.

Show code

HTML

JavaScript

11. chips — single-token closed-list picker

Only values from the list are valid: the committed member becomes a removable chip and the typed text is only ever a query. The chip is one unit — Backspace in the empty input removes it whole, typing with it present replaces it, and its × (or Delete on the focused chip) removes it. Free-form Enter commits nothing. An author-supplied required is mirrored onto the selection, so native form validation blocks until a member is picked.

Chips do not support inline text completion. With mode="inline" or "both", the component uses list and logs a warning. Inline completion commits text, while a chip selects an item that must be removed or replaced as a whole. Supporting both would require a design for item selection and restoration of the typed query; changing the attribute alone cannot provide it.

Show code

HTML

JavaScript

12. multiple — tag input

Any number of chips, each removable. With min-chars="0" the list stays open after each pick and already-selected items leave it (they resurface when removed). One hidden field per chip, so the form submits like a checkbox group — read with FormData.getAll(name). ← at the start of the input steps into the chip row; ←/→ traverse it.

Show code

HTML

JavaScript

How it behaves

Which item the inline suggestion shows

The ghost suffix previews what Tab, →, End or Enter at the end of the input would commit. It comes from an item whose format(item) starts with the typed text, compared without regard to case, so lowercase typing still matches title-case items. Diacritics still count: typed te does not match Téa.

After arrow navigation only the highlighted item can supply the suggestion; showing another item's suffix would contradict the highlight. Otherwise the first rendered item that still matches supplies it, not only the first item. For example, typed Lov keeps Lovech suggested from the previous results while the new query is still loading. Disabled items never supply it, because none of those keys can commit them.

While the user types, the input keeps exactly what they typed, in their own casing; the ghost adds the rest of the canonical text. Typed PAR against Paris therefore reads PARis until the commit writes format(item).

Arrow-key preview

The first ↓ or ↑ saves the typed text. Each option the arrow keys highlight then replaces the input value with format(item), so the user sees what Enter would commit. The component fires input on the host for each preview but runs no new search. Moving past either end of the list, Esc, or leaving the field without committing restores the saved text. A run of disabled options at an end wraps the same way, because nothing further can be reached in that direction.

A highlight from the pointer, or the automatic highlight of the option matching the input when the list reopens, only marks the option. It saves nothing and changes nothing in the input, so Esc then just closes the list. The next arrow key moves on from that option and starts the preview.

Requests and stale responses

Each typed query passes through to_query, and the promise source() returns is memoized under that canonical query, for up to 50 queries with the oldest dropped first. A request that is not memoized aborts every older request still running. A response older than the one already rendered is dropped, and so is an older error. A rejected promise is removed from the memo, aborts included, so the same query calls source() again next time instead of staying in the loading state. An AbortError is otherwise silent; any other error renders an error row and is announced through the component's live region, because the listbox itself is not a live region.

When the list opens

A request opens the list immediately, so a slow network still gives feedback at once; existing options stay in place. data-loading="true" and, in an otherwise empty list, a … row appear only after 300 ms, so fast responses cause no flash. Focusing the input with min-chars="0" prefetches, so the list has content the first time it opens.

An empty query with no results closes the list rather than showing the empty row: "No matches" for nothing typed means nothing, and on an autocomplete="email" field it would cover the browser's autofill popup.

Accessibility

Handling Escape

Escape can restore the typed query after arrow navigation, close the list or clear the input. In chips mode it clears only the query. When it handles any of these, the component cancels the event without stopping propagation. Ancestor handlers must check e.defaultPrevented. Checking whether a list is open is insufficient: restoring a query leaves it open, and clearing an input does not require an open list. The combobox's own keydown handler likewise ignores events that another handler has already cancelled.

When the list is closed and there is no tentative query or input to clear, the combobox neither cancels nor stops Escape. The enclosing dialog or form can then handle it. The list uses popover="manual" so a browser close watcher cannot act independently of this handler, as it would with auto.

Check e.defaultPrevented to distinguish an Escape event the combobox handled from one it left alone. Menus and notification panels also cancel without stopping propagation. A popover additionally stops propagation, so its handled Escape events do not reach bubbling ancestor listeners.

A select also handles Escape only when it has something to close. Its picker uses popover="auto", however, so cancellation must also prevent the browser's close watcher from closing an enclosing overlay after the picker has closed. The combobox uses popover="manual" and has no browser close watcher.

Labeling

The inner <input> carries role="combobox", so its accessible name is what screen readers announce. Three patterns, in preference order:

  1. Visible <label for> — preferred. Give the author-supplied input an explicit id and point a <label> at it. The label stays visible when the user types, which helps everyone.
  2. Visually-hidden <label for> — when a visible label would be redundant (e.g. a search field whose purpose is obvious from surrounding context). The label is still in the DOM and announced by screen readers.
  3. aria-label on the input — last resort, for cases like a decorated .input-group where adding a separate label element would break the layout. Put aria-label directly on the author-supplied <input>.

Do not rely on placeholder as the accessible name. Placeholders vanish once the user types, and they cause the listbox's own accessible name (which mirrors the input) to become the typed query rather than the field purpose.

Ghost-layer a11y trade-off

The inline completion is rendered by a sibling DOM mirror (marked aria-hidden), not by mutating input.value. This avoids async-input-event races on mobile virtual keyboards and during IME composition, at the cost of losing the traditional screen-reader channel for inline autocomplete. Arrow-nav, however, DOES update input.value programmatically (Google-style preview); this surfaces the canonical option to assistive tech the moment the user arrows to it.

See also