Skip to content

Multicombobox

hc-multicombobox is a multi-select combobox with a tag-input control. Selected values render as inline chips inside a single visual surface, the filter input lives next to them, and the listbox carries aria-multiselectable="true" so it doesn’t close after each pick.

Same architectural primitives as hc-combobox — WAI-ARIA 1.2 combobox pattern, HTML popover, CSS Anchor Positioning, aria-activedescendant for the highlighted option while DOM focus stays on the input.

  • JavaScript
  • TypeScript
  • Python
  • Go
  • Rust (coming soon)
import { installMulticombobox } from '@hypermedia-components/core';
installMulticombobox();

The pre-selected aria-selected="true" option becomes the seeded tag.

PartRequiredPurpose
hc-multicomboboxyesWrapper; carries data-name / data-allow-create.
hc-multicombobox__controlyesThe visual control box (pair it with hc-input).
hc-multicombobox__tagsyesEmpty container the behavior fills with tag chips.
hc-multicombobox__inputyesThe filter input — role="combobox".
hc-multicombobox__listboxyesThe option list — role="listbox".
hc-multicombobox__optionyesOne per option — role="option" + data-value.
hc-multicombobox__tag / __tag-removegeneratedA tag chip and its × remove button.
hc-multicombobox__hiddengeneratedHidden-input container for data-name form integration.
hc-multicombobox__emptygenerated”No matches” placeholder while the filter has no hit.
hc-multicombobox__creategeneratedThe synthetic “Add …” option (data-allow-create).

You author the yes parts; the generated ones are created and removed by installMulticombobox() — never write them yourself. The empty placeholder’s text comes from data-hc-empty on the listbox when present, else from the i18n key multicombobox.empty (see the i18n catalog):

<ul class="hc-multicombobox__listbox" id="lang-list" role="listbox"
data-hc-empty="No matching language">
  • ARIA: aria-multiselectable="true" on the listbox, aria-haspopup="listbox", aria-autocomplete="list", aria-expanded, aria-controls (if missing), aria-activedescendant tracking the highlighted option.
  • Anchor binding: inline anchor-name on the input, position-anchor on the listbox. JS positioning fallback for browsers without CSS Anchor Positioning.
  • Seeding: every option with aria-selected="true" at install time becomes a tag chip — keeps SSR-pre-selected state trivial.
  • Filtering: case-insensitive substring on each keystroke; .hc-multicombobox__empty placeholder when nothing matches.
  • Toggle selection: click or Enter on a highlighted option toggles its selected state. The listbox stays open so several picks can be made without reopening.
  • Tag removal:
    • Click the × button on a tag.
    • Press Backspace while the input is empty to drop the last tag (the standard tag-input convention).
  • Form integration: when the wrapper has data-name="X", the behavior writes one <input type="hidden" name="X" value="…"> per selected value inside the wrapper. The form serialises like a native <select multiple name="X">.
  • Events: every state change dispatches hc:multicomboboxchange on the input with detail.{values, added, removed, input}.
KeyAction
/ Open the listbox / move the activedescendant.
Home / EndFirst / last enabled visible option.
EnterToggle the highlighted option.
Backspace (input empty)Remove the last tag.
EscapeClose the listbox; selections preserved.
TabClose the listbox; normal tab order.

Set data-name on the wrapper for native form serialisation:

<form action="/save" method="post">
<div class="hc-multicombobox" data-name="languages"></div>
<button class="hc-button">Save</button>
</form>

Submits as languages=js&languages=ts&languages=py — the same shape a native <select multiple name="languages"> would produce. PHP / Rails / Python frameworks parse this without configuration.

The change event carries the full state, so an htmx swap on every edit is one trigger declaration:

<div class="hc-multicombobox"
data-name="languages"
data-hx-post="/profile/languages"
data-hx-trigger="hc:multicomboboxchange from:closest .hc-multicombobox"
data-hx-include="this"
data-hx-target="#status">
</div>

The server persists the values and returns a small confirmation fragment for #status (a “Saved” note, a validation message) — the control itself is not re-rendered; its tags and hidden inputs are client state.

Async options — to load the option list from the server, swap the listbox’s children instead: the behavior reads options live from the DOM, so swapped-in rows filter, highlight, and toggle like static ones.

<input class="hc-multicombobox__input" type="text" name="q"
role="combobox" aria-controls="lang-list" aria-label="Languages"
data-hx-get="/languages/options"
data-hx-trigger="input changed delay:300ms"
data-hx-target="#lang-list" data-hx-swap="innerHTML">

The server returns only <li class="hc-multicombobox__option" role="option" data-value="…"> rows matching the typed q.

Add data-allow-create to let users add tags that aren’t in the list. When the typed text has no exact match, a synthetic “Add …” option appears; choosing it (click or Enter) creates a tag from the raw text and fires hc:multicomboboxchange with the new value in added.

<div class="hc-multicombobox" data-name="tags" data-allow-create></div>

The created tag’s label is the value itself, and a hidden name input is added like any other tag. The option label is translatable via the i18n catalog (multicombobox.create).

Options can hold any HTML (icon + label + description). Use data-label for the tag’s label and the matched value, and data-search for the filter haystack (aliases/keywords), so the rich markup’s text doesn’t pollute either:

  • Python scripting
  • JavaScript web
  • Go services

If you set a display on options for the rich layout, scope it to :not([hidden]) so it doesn’t override the filter’s hide.

  • DOM focus stays on the input so the type-ahead anchor is consistent — aria-activedescendant moves the highlight, not the focus.
  • Each tag is a real focusable button with aria-label="Remove …" so screen-reader users can land on it and trigger removal.
  • Mark unavailable rows with aria-disabled="true" rather than disabled (<li> is not a form control).
  • The visible activedescendant highlight uses --hc-color-focus-ring and follows data-color themes.
  • Drag-to-reorder tags.
  • Async option loading — the docs above show the htmx swap pattern; a built-in debounce / cancel helper is not bundled.

Component tokens (in component.tokens.json):

Token pathPurpose
multicombobox.control.padding-x / padding-y / gap / min-heightTag-input control box.
multicombobox.input.min-width / fgInline text input.
multicombobox.tag.bg / fg / border / padding-x / padding-y / radius / font-size / gapTag chip.
multicombobox.tag.remove-fg / remove-hover-fg× button.
multicombobox.listbox.*Mirror of the combobox listbox tokens.
multicombobox.option.{padding-x, padding-y, font-size, fg, hover-bg, active-bg, check-color, disabled-fg, indicator-size}Option layout + check mark.
multicombobox.empty-fg”No matches” placeholder color.
Show the generated CSS variables
--hc-multicombobox-control-padding-x | -control-padding-y | -control-gap | -control-min-height
--hc-multicombobox-input-min-width | -input-fg
--hc-multicombobox-tag-bg | -tag-fg | -tag-border | -tag-radius | -tag-gap | -tag-font-size
--hc-multicombobox-tag-padding-x | -tag-padding-y
--hc-multicombobox-tag-remove-fg | -tag-remove-hover-fg
--hc-multicombobox-listbox-bg | -listbox-fg | -listbox-border | -listbox-radius
--hc-multicombobox-listbox-max-height | -listbox-padding-block | -listbox-min-width | -listbox-offset
--hc-multicombobox-option-padding-x | -option-padding-y | -option-font-size | -option-fg
--hc-multicombobox-option-hover-bg | -option-active-bg | -option-check-color
--hc-multicombobox-option-disabled-fg | -option-indicator-size
--hc-multicombobox-empty-fg
  • Combobox — single-select sibling.
  • Select — native single-select with the OS picker on mobile.
  • Checkbox — fixed-list multi-select pattern when the options are short and always visible.