Skip to content

Combobox

hc-combobox is an accessible single-select input with a type-to-filter dropdown. Follows the WAI-ARIA 1.2 combobox pattern — the <input> carries role="combobox" and the dropdown is a <ul role="listbox" popover>. Keyboard navigation uses aria-activedescendant so the visible highlight moves with the user’s selection while the actual DOM focus stays on the input.

Same architectural primitives as hc-menu and hc-tooltip:

  • HTML popover attribute for show / hide + Escape / outside dismiss.
  • CSS Anchor Positioning for the listbox placement under the input.
  • installCombobox() wires the filter, keyboard, selection, and ARIA bookkeeping.
PrimitiveRequired version
HTML popoverChrome 114, Edge 114, Firefox 125, Safari 17
CSS Anchor PositioningChrome 125, Edge 125, Firefox 147, Safari 26

Browsers without anchor support fall back to a getBoundingClientRect positioning hook.

  • Japan
  • United States
  • United Kingdom
  • France
  • Germany (coming soon)
import { installCombobox } from '@hypermedia-components/core';
installCombobox();
  • ARIA wiring on the input: aria-haspopup="listbox", aria-autocomplete="list", aria-expanded (toggled with the popover state), aria-controls if missing, aria-activedescendant tracking the highlighted option.
  • Auto-set popover="manual" on the listbox if the author did not supply a value.
  • Anchor binding via inline anchor-name on the input and position-anchor on the listbox so the popover lands directly under the input.
  • Filter: each input keystroke hides options whose text does not contain the typed string (case-insensitive). A .hc-combobox__empty <li role="presentation"> appears when nothing matches.
  • Keyboard:
    • opens / moves to the next visible option;
    • moves to the previous;
    • Home / End jump to the first / last visible enabled option;
    • Enter selects;
    • Escape closes the listbox without altering the input value;
    • Tab closes and yields to the next tab stop.
  • Mouse: clicking an option selects it. aria-disabled="true" options are skipped.
  • Selection: fills the input value, dispatches a bubbling hc:comboboxselect event whose detail carries { value, label, option, input }.

A selection fires hc:comboboxselect on the input; wire htmx to that event to push the choice to the server.

<input class="hc-combobox__input hc-input"
type="text"
role="combobox"
aria-controls="user-list"
aria-label="Assignee"
data-hx-post="/issues/123/assignee"
data-hx-trigger="hc:comboboxselect"
data-hx-vals='js:{ value: event.detail.value }'>

Add data-remote to the .hc-combobox to let the server filter. The behavior turns off its client-side filter and surfaces the loading / empty / error states from the htmx request lifecycle:

<div class="hc-combobox" data-remote>
<input class="hc-combobox__input hc-input" type="text" role="combobox"
aria-controls="city-list" aria-label="City"
data-hx-get="/cities" data-hx-trigger="input changed delay:200ms"
data-hx-target="#city-list">
<ul class="hc-combobox__listbox" id="city-list" role="listbox"></ul>
</div>

The server returns just the matching <li role="option"> rows and htmx swaps them into the listbox. While the request is in flight the behavior shows a spinner row and sets aria-busy="true"; an empty result shows the “No matches” marker; a failed response shows an error row. The three messages are translatable via the i18n catalog (combobox.loading / combobox.empty / combobox.error) or overridable per-listbox with data-hc-loading / data-hc-empty / data-hc-error.

Debouncing and cancel-in-flight stay with htmx (delay:200ms on the trigger, hx-sync) — the behavior never makes the request itself.

Add data-allow-create to let the user pick a value that isn’t in the list. When the typed text has no exact match, a synthetic “Create …” option appears at the end of the listbox; choosing it (click or Enter) commits the raw text and fires hc:comboboxselect with created: true.

<div class="hc-combobox" data-allow-create></div>
input.addEventListener('hc:comboboxselect', (e) => {
// e.detail = { value, label, option, input, created }
if (e.detail.created) {
// persist the new value (POST it, add it to the list, …)
}
});

The label is translatable via the i18n catalog (combobox.create, e.g. Create "{value}").

Options can hold any HTML — an icon, a two-line label, a description. Two optional attributes keep filtering and selection clean:

  • data-label — the value written into the input on select (otherwise the option’s text content, which would include the rich markup’s text).
  • data-search — the text the filter matches against (otherwise the label). Use it to match aliases / keywords that aren’t shown.
<li class="hc-combobox__option" role="option" data-value="jp"
data-label="Japan" data-search="japan nippon 日本 jp">
<img src="/flags/jp.svg" alt=""> <strong>Japan</strong> <small>Asia</small>
</li>

The filter hides non-matching options with the hidden attribute. If you give options a display (flex/grid) for the rich layout, scope it to :not([hidden]) so it doesn’t override the hide:

.hc-combobox__option:not([hidden]) { display: grid; … }
  • The input is the user’s anchor — focus stays there at all times so type-ahead and screen-reader announcement track correctly. aria-activedescendant moves the highlight without moving DOM focus.
  • Always give the combobox an accessible name. Wrap with a <label>, set aria-label, or use aria-labelledby.
  • The listbox carries role="listbox" and each child role="option". Mark unavailable rows with aria-disabled="true" rather than disabled<li> is not a focusable form control, so disabled has no effect.
  • The visible focus indicator on the active option uses --hc-color-focus-ring and stays in sync across data-color themes.

The MVP keeps the surface small. The following patterns are deferred:

  • Multi-select — tag input with multiple chosen values. Will ship as a separate hc-multicombobox.
  • Built-in debounce / cancel-in-flight — async loading itself is supported (see Remote (async) options), but request timing stays with htmx (delay: on the trigger, hx-sync) rather than being bundled into the behavior.
  • Strict mode — accepting free text as a value is supported via data-allow-create; the opposite — clearing the input on close when it doesn’t match an option — is still a future opt-in flag.

Component tokens (in component.tokens.json):

Token pathPurpose
combobox.listbox.bg / fg / border / radiusListbox surface.
combobox.listbox.max-heightCap before the listbox scrolls.
combobox.listbox.padding-block / min-width / offsetListbox spacing and anchor offset.
combobox.option.padding-x / padding-y / font-size / gapOption layout.
combobox.option.fg / hover-bg / active-bg / selected-bg / selected-fg / disabled-fgOption states.
combobox.empty-fg”No matches” placeholder color.
Show the generated CSS variables
--hc-combobox-listbox-bg | -listbox-fg | -listbox-border | -listbox-radius
--hc-combobox-listbox-max-height | -listbox-padding-block
--hc-combobox-listbox-min-width | -listbox-offset
--hc-combobox-option-padding-x | -option-padding-y | -option-font-size
--hc-combobox-option-fg | -option-hover-bg | -option-active-bg
--hc-combobox-option-selected-bg | -option-selected-fg
--hc-combobox-option-disabled-fg
--hc-combobox-empty-fg
--hc-color-focus-ring (inherited from data-color)
  • Select — when the list is short and you want the native OS picker on mobile.
  • Menu — when the items are actions, not values.
  • Input — plain text input.