Skip to content

Combobox

<cinq-combobox> adds keyboard support, popup visibility, and ARIA state to combobox markup you provide.

Inspired by @19h47/combobox and the WAI-ARIA combobox pattern.

Terminal window
pnpm add @agencecinq/combobox

Import once:

import "@agencecinq/combobox";

Write the combobox markup, then assign search on the host. The component mounts once the element is connected and search is set.

<label for="monster">Monster</label>
<cinq-combobox id="monster-combobox">
<input
id="monster"
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="monster-listbox"
autocomplete="off"
/>
<button
type="button"
tabindex="-1"
aria-label="Monsters"
aria-controls="monster-listbox"
aria-expanded="false"
>
Open
</button>
<ul id="monster-listbox" role="listbox" aria-label="Monsters" hidden></ul>
</cinq-combobox>
const host = document.querySelector("#monster-combobox");
host.search = (value, { signal }) => {
if (value.length < 1) return monsters;
return monsters.filter((name) =>
name.toLowerCase().startsWith(value.toLowerCase()),
);
};

An open button is optional. Wire the toggle and sync aria-expanded / disabled yourself:

const button = host.querySelector("button[aria-controls]");
const syncButton = () => {
button.setAttribute("aria-expanded", host.expanded ? "true" : "false");
button.disabled = host.disabled;
};
button.addEventListener("mousedown", (event) => event.preventDefault());
button.addEventListener("click", (event) => {
event.stopPropagation();
if (host.disabled) return;
if (host.expanded) host.hide({ force: true });
else void host.ensureOpen().then(() => host.$input?.focus());
});
new MutationObserver(syncButton).observe(host, {
attributes: true,
attributeFilter: ["expanded", "disabled"],
});
syncButton();

search may return an array or a Promise. Stale responses are ignored. The previous AbortSignal is aborted when a newer search starts.

Mode Who owns the listbox DOM Typical use
managed (default) Library rebuilds options from string[] via render Classic autocomplete
external Consumer injects HTML / mutates the listbox Rich results, grouped options
Selector / attribute Required Role
<cinq-combobox> Yes Host wrapper.
input or [role="combobox"] Yes Focusable textbox.
[role="listbox"] with stable id Yes Popup list, referenced by aria-controls.
role="combobox" + aria-autocomplete="list" Yes On the input.
aria-controls / aria-expanded Yes On the input (and optional button).
Optional button[aria-controls] inside the host No Optional pointer/touch toggle. Wire it in your app.
[role="option"] with stable id In external mode Generated in managed mode from {listboxId}-option-{n}.

Configure via data attributes on the host (observed at runtime), or properties for callbacks:

Attribute / property Type Default Description
value string "" Mirrored textbox value. Setting it updates the input without opening the list or firing combobox:submit.
disabled boolean false Disables the input and closes the list if open.
expanded boolean false Reflected open state. Setting it opens / closes the list from outside.
busy boolean false Reflected while a search is in flight (mirrors aria-busy on the listbox).
search (property) SearchFn - Required. (value, { signal }) => SearchResult | Promise<...>
data-mode managed | external managed Who owns listbox markup
data-select-mode value | custom value Fill the textbox, or only fire onSelect / combobox:submit
data-debounce number 0 Debounce (ms) for input-driven searches
data-min-length number 0 Minimum trimmed length before searching
data-open-on-empty boolean false Keep popup open on empty results
data-autoselect boolean false Highlight the first suggestion
render (property) (label, props) => string <li> HTML for each managed option
write (property) (input, value) => void sets input.value How a chosen option is written
onSelect (property) (detail) => void - Called when an option is accepted

In managed mode, override render to control option markup. Use serializeOptionAttrs so id, role, and ARIA stay correct:

import { serializeOptionAttrs } from "@agencecinq/combobox";
host.render = (label, props) =>
`<li${serializeOptionAttrs(props)} class="my-option">${label}</li>`;

optionRenderProps(index, selectedIndex, listboxId, size) is also exported if you build props yourself.

When selectMode is value (default), write runs on accept:

host.write = (input, value) => {
input.value = value.toUpperCase();
};

Default (value): accepting an option writes its label into the input (classic autocomplete).

Custom: the input is only a filter. Accepting an option fires combobox:submit / onSelect but does not change the textbox. You store or display the choice elsewhere (link, chip, hidden field, router navigation, etc.).

<cinq-combobox id="monster-picker" data-select-mode="custom">
<input
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="monster-listbox"
placeholder="Filter monsters"
/>
<ul id="monster-listbox" role="listbox" hidden></ul>
</cinq-combobox>
<p>Picked: <span id="monster-picked">-</span></p>
import { EVENTS } from "@agencecinq/utils";
const host = document.querySelector("#monster-picker");
const picked = document.querySelector("#monster-picked");
host.search = (query) =>
monsters.filter((name) =>
name.toLowerCase().startsWith(query.toLowerCase()),
);
host.addEventListener(EVENTS.COMBOBOX_SUBMIT, ({ detail }) => {
// detail.value = chosen monster. Input still holds the filter ("be", etc.)
picked.textContent = detail.value;
});

Try it in the Custom select playground section: type be, pick Beholder. Query stays be. Picked shows Beholder.

For a fuller UI (rich external options, detail panel, empty state), see Compendium (advanced) in the playground.

Combine external mode, custom select, and your own markup to build command-palette or compendium UIs:

  • search returns grouped HTML ({ html }) with avatars, badges, metadata on each [role="option"]
  • data-select-mode="custom" keeps the filter in the input. combobox:submit drives a detail panel
  • data-* on options (data-cr, data-type, etc.) are read in the submit handler
  • pair combobox:empty with a visible empty state and data-debounce for async feel

The Compendium (advanced) playground demo wires all of this end-to-end.

Return Mode Effect
string[] managed Rebuild options via render
HTMLElement[] either Replace listbox children
{ html } external Set listbox innerHTML
{ options: string[] | HTMLElement[] } either Same as the array forms above
host.setValue("Mind Flayer"); // or host.value = "..."
host.setAttribute("expanded", ""); // open
host.removeAttribute("expanded"); // close
host.setAttribute("disabled", "");

While a search is in flight, the host reflects [busy] and the listbox gets aria-busy="true".

Hook Element When
[hidden] listbox Popup closed
[aria-expanded="true"] input / button Popup open
[aria-busy="true"] listbox Search in flight
[expanded] / [busy] / [disabled] <cinq-combobox> Mirrored host state
[aria-selected="true"] option Visual focus
ul[role="listbox"][hidden] {
display: none;
}
ul[role="listbox"]:not([hidden]) {
display: flex;
flex-direction: column;
max-height: 16rem;
overflow: auto;
}
[role="option"][aria-selected="true"] {
background: #e9ecef;
}
Method Description
setValue(value) Set the textbox value from outside (also available as the value property / attribute).
show() / hide(options?) Open or close the listbox (hide: { force?, clear? })
select() Accept the focused option
destroy() Remove listeners, clear debounce, abort in-flight searches

Readable state: index, options, value, loading, expanded, focused, disabled.

Dispatched on the host <cinq-combobox> (bubble). Constants live on @agencecinq/utils EVENTS:

Event Constant Detail When
combobox:loading COMBOBOX_LOADING - Before search resolves
combobox:loaded COMBOBOX_LOADED - After a non-stale search resolves
combobox:update COMBOBOX_UPDATE { options, index, value } After options / ARIA are synced
combobox:submit COMBOBOX_SUBMIT { option, index, value } When an option is chosen
combobox:empty COMBOBOX_EMPTY { value } No options (or below minLength)
import { EVENTS } from "@agencecinq/utils";
$host.addEventListener(EVENTS.COMBOBOX_SUBMIT, ({ detail }) => {
console.log(detail.option, detail.value);
});
Key Function
ArrowDown Open list and move to first / next option
Alt + ArrowDown Open list without moving visual focus
ArrowUp Open list and move to last / previous option
Alt + ArrowUp Open list without moving visual focus
Enter Accept focused option. Without focus, close and allow form submit
Escape Close list, or clear field if already closed
Tab Accept focused option, otherwise close
Home / End / ArrowLeft / ArrowRight Return editing to the textbox when an option has visual focus

DOM focus stays on the textbox. Visual focus uses aria-activedescendant.

The component does not position the popup. Style the listbox yourself. For placement that flips near the viewport edge, use CSS Anchor Positioning:

cinq-combobox {
anchor-name: --combobox-anchor;
anchor-scope: --combobox-anchor; /* one name per host on the page */
}
cinq-combobox [role="listbox"] {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
right: 0;
}
@supports (anchor-name: --x) and (position-anchor: --x) {
cinq-combobox [role="listbox"] {
position: fixed;
position-anchor: --combobox-anchor;
top: anchor(bottom);
left: anchor(left);
width: anchor-size(width);
right: auto;
margin-top: 0.25rem;
position-try-fallbacks: flip-block;
}
}

position: fixed + anchor keeps the list aligned to the input. flip-block opens upward when there is not enough space below.

Local list

Managed mode: sync search, optional open button, anddata-autoselect (first match highlighted on open).

Status: -

Async search

Managed mode: async search, AbortSignal, event log, and[busy] while loading.

Status: Idle

External mode

Grouped HTML, debounce, minLength, and combobox:empty.

Hint: Idle. Try "be", "mi", or "xx".

Custom select

By default, choosing an option fills the input (see Local list above). With data-select-mode="custom", the input stays your filter. Handle the choice in combobox:submit.

  1. Type be in the field below.
  2. Open the list (Down arrow or click the input).
  3. Pick an option (click or Enter).
  4. Query stays be. Only Picked updates.
Query (unchanged on pick)
-
Picked
-

Compendium (advanced)

External mode and selectMode: custom: rich grouped options, debounced async search, empty state, and a detail panel fed from combobox:submit.

Filter:-

Down arrow to navigate, Enter to inspect. The filter stays in the field.