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.

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

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:

<li class="hc-multicombobox__option" role="option" data-value="py"
data-label="Python" data-search="python py snake">
<strong>Python</strong> <small>scripting</small>
</li>

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