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.
  • Japan
  • United States
  • United Kingdom
  • France
  • Germany (coming soon)
import { installCombobox } from '@hypermedia-components/core';
installCombobox();

installCombobox() is idempotent and returns an uninstaller. The zero-config @hypermedia-components/core/behaviors entry auto-installs it and picks up content swapped in by htmx.

  • 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 }.

The combobox input is a real <input> — give it a name and it submits with the form natively:

<div class="hc-combobox">
<input class="hc-combobox__input hc-input" type="text" name="country"
role="combobox" aria-controls="cb-list" aria-label="Country" />
<!-- listbox as above -->
</div>

What gets submitted is the visible text. On select, installCombobox() writes the option’s label into the input (Japan), not its data-value (jp); the user can also type free text, so the wire value is always exactly what they see. Validate it server-side like any other text field.

When the server needs the code value instead, mirror it into a hidden input from the select event (detail carries { value, label }):

<input type="hidden" name="country" id="country-code">
document.addEventListener('hc:comboboxselect', (e) => {
document.getElementById('country-code').value = e.detail.value ?? '';
});

For htmx round trips the same event can drive a request instead — see below.

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.
  • 🇯🇵 Japan Asia
  • 🇫🇷 France Europe
  • 🇧🇷 Brazil South America

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; … }
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.

  • 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.