Combobox Multi
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/combobox-multiPlain-CSS theme — no build step
npx astrocraft-ui add forms/combobox-multi --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/combobox-multi --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/combobox-multi --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/combobox-multi --theme tailwind --bridge lumosLive demo
ComboBoxMulti
Multi-select autocomplete over the same ComboBoxOptions the single-select ComboBox uses, with TagsInput's chips for what you have chosen.Enter toggles and clears the query, Backspace on an empty box removes the last chip. The active option is marked data-active so thataria-selected can keep meaning "chosen" — see the file header on why that costs twelve lines rather than a change to a frozen module.
- Astro
- Svelte
- Solid
- Vue
- Qwik
- Lit
- No results
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `ComboBoxMulti.astro` | `combobox-empty` `combobox-multi` `combobox-multi-input` `combobox-multi-list` `combobox-multi-status` `combobox-multi-tags` `combobox-multi-template` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | `data-active` `data-value` `data-option` | `[aria-expanded]` `[hidden]` |
Source
What the command copies — 2 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/combobox-multi/ComboBoxMulti.astro — headless primitive (see ../../README.md).
// Multi-select autocomplete: type to filter, Enter or click to toggle an option, chosen options
// appear as removable chips, and each chip carries a hidden input so the field submits as a repeated
// `name` with nothing to parse on the server.
//
// It composes two primitives that already exist rather than restating them: the options are
// <ComboBoxOption> (same markup, same `data-value` / `data-label`, same check glyph) and the chips
// are <TagsInputTag>, cloned from a <template> so no markup is built in JavaScript.
//
// WHY IT DOES NOT USE `createActiveDescendant`, which the roadmap expected it to. That helper marks
// the ACTIVE option with `aria-selected` — correct for a single-select combobox, where active and
// chosen are the same thing. Here they are not: `aria-selected` has to mean "you have chosen this
// one", or a screen reader announces every chosen option as unselected the moment you arrow past it.
// So the active option is tracked with `data-active` instead, and the rest of `_listbox.ts` —
// `filterByText` and `nextIndex` — is used as it stands. That module is one of the four kept
// byte-identical to the theme repo (see AGENTS.md, "Sync policy"), so bending it was never an
// option; the twelve lines below are the price, paid knowingly.
//
// ponytail: client-side, static options, substring matching — the same ceiling as ComboBox. Fetch
// and render options server-side per page if you need a remote source.
import type { HTMLAttributes } from "astro/types";
import { TagsInputTag } from "../../forms/tags-input";
type Props = HTMLAttributes<"input"> & {
/** Repeated on every chip's hidden input — the name the chosen values submit under. */
name?: string;
label?: string;
placeholder?: string;
emptyText?: string;
removeLabel?: string;
max?: number;
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
};
const {
name,
label = "Choose options",
placeholder = "Search…",
emptyText = "No results",
removeLabel = "Remove",
max,
size = "md",
state = "default",
class: className,
...rest
} = Astro.props;
const listId = `combobox-multi-${crypto.randomUUID().slice(0, 8)}`;
---
<div data-slot="combobox-multi" data-max={max}>
<ul data-slot="combobox-multi-tags"></ul>
<input
type="text"
role="combobox"
aria-expanded="false"
aria-controls={listId}
aria-autocomplete="list"
aria-label={label}
autocomplete="off"
placeholder={placeholder}
data-slot="combobox-multi-input"
data-size={size}
data-state={state}
class={className}
{...rest}
/>
<ul
id={listId}
role="listbox"
aria-multiselectable="true"
aria-label={label}
data-slot="combobox-multi-list"
hidden
>
<slot />
<li data-slot="combobox-empty" role="presentation" hidden>{emptyText}</li>
</ul>
<template data-slot="combobox-multi-template">
<TagsInputTag name={name} removeLabel={removeLabel} />
</template>
{
/* Adding or removing a chip moves nothing and announces nothing on its own — this says how many. */
}
<p role="status" aria-live="polite" data-slot="combobox-multi-status"></p>
</div>
<script>
import { filterByText, nextIndex } from "../../_listbox";
import { onReadyOnce } from "../../_once";
let uid = 0;
function wire(root: HTMLElement) {
const input = root.querySelector<HTMLInputElement>('[data-slot="combobox-multi-input"]');
const list = root.querySelector<HTMLElement>('[data-slot="combobox-multi-list"]');
const tags = root.querySelector<HTMLElement>('[data-slot="combobox-multi-tags"]');
const template = root.querySelector<HTMLTemplateElement>(
'[data-slot="combobox-multi-template"]',
);
const status = root.querySelector<HTMLElement>('[data-slot="combobox-multi-status"]');
const empty = root.querySelector<HTMLElement>('[data-slot="combobox-empty"]');
if (!input || !list || !tags || !template) return;
const options = [...list.querySelectorAll<HTMLElement>('[data-slot="combobox-option"]')];
options.forEach((option) => (option.id ||= `combobox-multi-opt-${(uid += 1)}`));
const max = Number(root.dataset.max);
// The local rover: `data-active` for where the keyboard is, so `aria-selected` is left to mean
// what it means in a multi-select listbox — chosen. See the header.
const active = () => options.find((option) => option.dataset.active === "true") ?? null;
const setActive = (option: HTMLElement | null) => {
for (const o of options) {
if (o === option) o.dataset.active = "true";
else delete o.dataset.active;
}
if (option) {
input.setAttribute("aria-activedescendant", option.id);
option.scrollIntoView({ block: "nearest" });
} else {
input.removeAttribute("aria-activedescendant");
}
};
const move = (dir: 1 | -1) => {
const visible = options.filter((option) => !option.hidden);
const current = active();
const i = nextIndex(visible.length, current ? visible.indexOf(current) : -1, dir);
if (i >= 0) setActive(visible[i]);
};
const chosen = () =>
options.filter((option) => option.getAttribute("aria-selected") === "true");
const open = () => {
list.hidden = false;
input.setAttribute("aria-expanded", "true");
};
const close = () => {
list.hidden = true;
input.setAttribute("aria-expanded", "false");
setActive(null);
};
const publish = () => {
const selected = chosen();
root.dataset.value = selected.map((option) => option.dataset.value ?? "").join(",");
if (status) status.textContent = String(selected.length);
root.dispatchEvent(new Event("change", { bubbles: true }));
};
const labelOf = (option: HTMLElement) =>
option.dataset.label ?? (option.textContent ?? "").trim();
const renderTags = () => {
const row = template.content.querySelector('[data-slot="tags-input-tag"]');
if (!row) return;
tags.replaceChildren();
for (const option of chosen()) {
const value = option.dataset.value ?? labelOf(option);
const chip = row.cloneNode(true) as HTMLElement;
chip.dataset.value = value;
chip.dataset.option = option.id;
const text = chip.querySelector('[data-slot="tags-input-tag-label"]');
const hidden = chip.querySelector<HTMLInputElement>('[data-slot="tags-input-value"]');
const remove = chip.querySelector('[data-slot="tags-input-remove"]');
if (text) text.textContent = labelOf(option);
if (hidden) hidden.value = value;
remove?.setAttribute(
"aria-label",
`${remove.getAttribute("data-label") ?? "Remove"} ${labelOf(option)}`,
);
tags.append(chip);
}
};
const toggle = (option: HTMLElement) => {
const isChosen = option.getAttribute("aria-selected") === "true";
// A full field still lets you UNchoose — refusing both directions is how a `max` traps people.
if (!isChosen && Number.isFinite(max) && chosen().length >= max) return;
option.setAttribute("aria-selected", String(!isChosen));
renderTags();
publish();
};
input.addEventListener("focus", () => {
filterByText(options, "", empty);
open();
});
input.addEventListener("input", () => {
open();
filterByText(options, input.value, empty);
setActive(options.find((option) => !option.hidden) ?? null);
});
input.addEventListener("keydown", (event) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (list.hidden) open();
move(1);
break;
case "ArrowUp":
event.preventDefault();
move(-1);
break;
case "Enter": {
const option = active();
if (!option) return;
// Enter belongs to the listbox while it is open; letting it through submits the form
// around a field the user is still filling in.
event.preventDefault();
toggle(option);
// The query has done its job — clearing it shows the whole list again, which is what you
// want when picking several things in a row.
input.value = "";
filterByText(options, "", empty);
break;
}
case "Backspace": {
if (input.value !== "") return;
const last = chosen().at(-1);
if (last) toggle(last);
break;
}
case "Escape":
close();
break;
}
});
for (const option of options) {
// mousedown fires before the input's blur, so preventDefault keeps focus where it is.
option.addEventListener("mousedown", (event) => event.preventDefault());
option.addEventListener("click", () => toggle(option));
option.addEventListener("mousemove", () => setActive(option));
}
tags.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (!target.closest('[data-slot="tags-input-remove"]')) return;
const id = target.closest<HTMLElement>('[data-slot="tags-input-tag"]')?.dataset.option;
const option = options.find((o) => o.id === id);
if (option) toggle(option);
input.focus();
});
root.addEventListener("focusout", (event) => {
if (!root.contains(event.relatedTarget as Node)) close();
});
renderTags();
publish();
}
onReadyOnce('[data-slot="combobox-multi"]', wire);
</script>
import ComboBoxMulti from "./ComboBoxMulti.astro";
export { ComboBoxMulti };
export default ComboBoxMulti;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/combobox-multi — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.