Combobox
A text input that filters a themable listbox — autocomplete, single- or multi-select, with chips.
Live demo
Field hands its suggestions to a native <datalist> and Select to a native <select>. Both are right for the common case — the platform's own control is accessible, familiar, and free.
Both are also drawn by the browser, which means a theming engine's tokens stop at their edge: the popup a <datalist> renders is the one surface in the set no algorithm can reach. Combobox is the answer to that. It draws the whole surface — the control, the caret, the floating listbox, the option rows, the chips — out of the register, so a theme actually reaches the thing the user is looking at.
It speaks the option contract Field and Select already speak (a string[], a { value, label }[], or a JSON options attribute), so moving a control across is a tag change rather than a rewrite. Typing filters the list (contains by default, starts for a prefix match, none when the server already filtered and a second pass here would fight it). allow-custom lets a value the list never offered through, which is what a search box or a free-tag field needs.
multiple turns it into the tag input: every pick becomes a removable chip in the control, Backspace on an empty query takes the last one back, the clear action empties the lot, and the listbox becomes aria-multiselectable with a check on each chosen row. In a <form> it posts one hidden input per selection under its name, the way a native multi-<select> does — plumbing that never renders.
The listbox floats in a <xtyle-popover>, so the anchoring, the flip near a viewport edge, the top-layer stacking, and the light-dismiss are the overlay family's and are not re-derived. Keyboard is the full WAI-ARIA combobox pattern: DOM focus never leaves the text input, and aria-activedescendant carries the cursor through the list.
When to use
How this component composes with the rest of the set.
Props
22 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
Appearance
Variants
multiple
The multi-select posture: the control grows a row of chips and the listbox marks every chosen option.
Sizes
sm
Compact — a toolbar filter, a dense table header.
md
The default, matching Field and Select.
lg
Roomy — a landing-page search, a primary picker.
States
open
The listbox is showing. The caret flips, and the input's aria-expanded says so.
active option
The keyboard cursor: the row aria-activedescendant points at. It is not a selection — an unpicked row can be active, and in multi-select a picked row can be passed over.
selected option
A chosen row, marked with a check. In multi-select, several are.
invalid
The control is invalid: the border takes the danger tone and the error line is announced.
disabled
Out of play — no typing, no opening, no clearing.
readonly
Shown but frozen: the selection reads, the list never opens.
Anatomy
The named parts that make up the component, with their selectors.
combobox
The wrapper: label, control, listbox, and the description / error lines under it.
label
The control's label, wired to the input by for. Hidden entirely when no label is given (name the control with aria-label instead).
control
The box the user types into: the chips, the input, the clear action, and the caret. It is also the rect the listbox anchors to, so the panel is always exactly as wide as the control.
chip
One selected value in multi-select, with its own remove button. This is the tag input: the chips are real nodes in the fragment, so a mod can reshape them into avatars, colored labels, or anything else.
input
The text box. It carries role="combobox", aria-expanded, aria-controls, and aria-activedescendant, and it never gives up focus while the list is open.
action
The clear and caret buttons. Both are tabindex="-1": they are pointer affordances for what the keyboard can already do (Escape clears, ArrowDown opens).
list
The listbox itself, floated in a popover panel and sized to the control. A real <ul role="listbox"> in the fragment — not a stylesheet's furniture — so a mod can rebuild the rows.
option
One row: its label, and the check that marks it selected. The keyboard cursor lands on it as data-active, which is a different thing from being chosen (aria-selected) — a multi-select shows both at once.
empty
What the panel says when the query matched nothing. Its text is empty-text.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--accent
--accent-bg
--accent-text
--bg-1
--border-normal
--border-thin
--danger
--danger-bg
--danger-text
--duration-fast
--ease-standard
--fg-0
--fg-1
--fg-2
--fg-disabled
--field-bg
--field-border
--font-sans
--leading-normal
--leading-tight
--placeholder
--radius-md
--radius-sm
--ring
--space-1
--space-2
--space-3
--state-disabled
--state-hover
--state-press
--state-selected
--text-lg
--text-sm
--weight-medium
--weight-semibold
Accessibility
Code
Autocomplete a single value
The common shape: type to filter, Arrow keys to move, Enter to commit. The popup is drawn from the register, so the theme reaches it.
<xtyle-combobox
id="tz"
label="Time zone"
placeholder="Start typing…"
clearable
></xtyle-combobox>
<script>
const combo = document.querySelector("#tz");
// the same option contract Field and Select speak: a string[] or a { value, label }[]
combo.options = [
{ value: "Europe/London", label: "London" },
{ value: "Europe/Berlin", label: "Berlin" },
{ value: "America/New_York", label: "New York" },
{ value: "Asia/Tokyo", label: "Tokyo" },
];
combo.addEventListener("change", () => console.log("picked:", combo.value));
</script>
<script lang="ts">
import { Combobox } from "@xtyle/svelte";
const zones = [
{ value: "Europe/London", label: "London" },
{ value: "Europe/Berlin", label: "Berlin" },
{ value: "America/New_York", label: "New York" },
];
let zone = $state("");
</script>
<Combobox label="Time zone" placeholder="Start typing…" options={zones} bind:value={zone} clearable />
<p>Picked: {zone || "nothing yet"}</p>
---
import Combobox from "@xtyle/astro/Combobox.astro";
const zones = [
{ value: "Europe/London", label: "London" },
{ value: "Europe/Berlin", label: "Berlin" },
{ value: "America/New_York", label: "New York" },
];
---
<Combobox label="Time zone" placeholder="Start typing…" options={zones} clearable />
Multi-select, with chips
multiple makes it the tag input: every pick becomes a removable chip, Backspace on an empty query takes the last one back, and the form posts one entry per selection.
<!-- multi-select: the picks become chips, Backspace on an empty query takes the last one back -->
<xtyle-combobox
id="labels"
label="Labels"
name="labels"
multiple
clearable
placeholder="Add a label…"
options='["bug", "docs", "enhancement", "good first issue"]'
values='["bug"]'
></xtyle-combobox>
<script>
const labels = document.querySelector("#labels");
labels.addEventListener("change", () => console.log(labels.values));
</script>
<script lang="ts">
import { Combobox } from "@xtyle/svelte";
let labels = $state(["bug"]);
</script>
<Combobox
label="Labels"
name="labels"
multiple
clearable
placeholder="Add a label…"
options={["bug", "docs", "enhancement", "good first issue"]}
bind:values={labels}
/>
<p>{labels.length} selected</p>
---
import Combobox from "@xtyle/astro/Combobox.astro";
---
<form method="post">
<!-- one hidden input per selection, so the form posts `labels` twice -->
<Combobox
label="Labels"
name="labels"
multiple
clearable
options={["bug", "docs", "enhancement"]}
values={["bug"]}
/>
<button type="submit">Save</button>
</form>
Server-filtered, with free text
filter="none" hands the filtering to the server: read query on input, fetch, and assign options back. allow-custom lets an answer the list never offered through.
<!-- `filter="none"` hands the filtering to the server: the component shows exactly
what it is given, and `allow-custom` lets a value the list never offered through -->
<xtyle-combobox id="search" label="Repository" filter="none" allow-custom placeholder="Search…"></xtyle-combobox>
<script>
const search = document.querySelector("#search");
search.addEventListener("input", async () => {
const query = search.query; // what was typed, not what was picked
const res = await fetch(`/api/repos?q=${encodeURIComponent(query)}`);
search.options = await res.json();
});
search.addEventListener("select", (event) => console.log(event.detail.value));
</script>
<script lang="ts">
import { Combobox } from "@xtyle/svelte";
let repos = $state<{ value: string; label?: string }[]>([]);
async function search(event: Event) {
const query = (event.currentTarget as HTMLElement & { query: string }).query;
repos = await (await fetch(`/api/repos?q=${encodeURIComponent(query)}`)).json();
}
</script>
<Combobox label="Repository" filter="none" allowCustom options={repos} oninput={search} />
---
import Combobox from "@xtyle/astro/Combobox.astro";
---
<Combobox id="search" label="Repository" filter="none" allowCustom placeholder="Search…" />
<script>
const search = document.querySelector("#search");
search.addEventListener("input", async () => {
const res = await fetch(`/api/repos?q=${encodeURIComponent(search.query)}`);
search.options = await res.json();
});
</script>