Combobox
Searchable autocomplete input for choosing from a long list of options.
Playground
<script lang="ts">
import { Combobox } from '@urbicon-ui/blocks';
const options = [
{ label: 'United States', value: 'us' },
{ label: 'United Kingdom', value: 'uk' },
{ label: 'Germany', value: 'de' },
{ label: 'France', value: 'fr' },
{ label: 'Japan', value: 'jp' },
{ label: 'Australia', value: 'au' },
{ label: 'Canada', value: 'ca' },
{ label: 'Brazil', value: 'br' }
];
</script>
<Combobox
{options}
placeholder="Search countries…"
/>01 Examples
Each option is an object with a label and a value, and options is an array of them. Bind a single selection with bind:value (the picked value, or null when empty), or pass multiple to bind an array rendered as removable tags. Reach
for Combobox over Select when the list is long enough to search or its values load from a server.
Select suits a short, fixed set.
Multi-select with tags
multiple to bind an array of values. Picks render as removable tag chips and the listbox stays open across selections. maxItems caps the count, and Backspace on an empty field removes the last tag.<Combobox
label="Skills"
options={languages}
multiple
bind:value={skillsValue}
maxItems={5}
placeholder="Add skills…"
clearable
/>Helper, error & required
error overrides helper when both are set.<Combobox
label="Timezone"
options={timezones}
bind:value={timezoneValue}
placeholder="Search…"
helper="We use this to schedule meetings"
required
clearable
/>
<Combobox
label="Primary language"
options={languages}
error="Please select your primary language"
placeholder="Search…"
/>Custom filter
startsWith matching suits command-style input.<Combobox
label="Language"
options={languages}
bind:value={filterValue}
placeholder="Type to match…"
filter={(opt: ComboboxOption, q: string) =>
opt.label.toLowerCase().startsWith(q.toLowerCase())}
/>Custom option renderer
customOption snippet for rich list items: an avatar, the name, and a role badge on each row.<Combobox
label="Team member"
options={teamMembers}
bind:value={customValue}
placeholder="Search team…"
clearable
>
{#snippet customOption(opt: ComboboxOption, isSelected: boolean)}
<div class="flex w-full items-center gap-3">
<Avatar src={avatars[opt.value]} size="xs" />
<span class="flex-1 truncate text-sm">{opt.label.split(' — ')[0]}</span>
<Badge
size="xs"
variant="soft"
intent={isSelected ? 'success' : 'neutral'}
class="shrink-0"
>
{opt.label.split(' — ')[1]}
</Badge>
{#if isSelected}
<CheckIcon size={14} class="text-primary shrink-0" />
{/if}
</div>
{/snippet}
</Combobox>02 Free Text
By default the option list is closed: a value that is not in it cannot be picked. Pass allowCustom and the list becomes a set of suggestions
instead — once the query matches no option's label, a trailing row offers to keep what was
typed. It behaves like any other option: the arrow keys reach it, Enter picks it, and onValueChange receives the typed text. What is stored is
the text itself, not the row's wording, so the field reads “Kino 46” afterwards and the row's label
is translated with the rest of the library.
Suggestions, not a closed list
clearable resets it, and the value is a plain string either way — pick one from the list or invent one, the binding does not change.Value: null
<Combobox
label="Venue"
options={venues}
bind:value={venueValue}
allowCustom
clearable
placeholder="Search or type a venue…"
/>
<p class="text-text-tertiary text-xs">
Value: <code class="text-text-primary">{venueValue ?? 'null'}</code>
</p>The suggestions may come from anywhere — options, groups or a queryFn; the row waits for the results and then
sits below the last group. It always draws itself, so customOption never receives it — the option behind it
is in none of your arrays. If the row should look different, or store something other than the
typed text, leave allowCustom off and append an option of
your own instead.
03 Async Search
Pass queryFn and the Combobox stops filtering
client-side. On each query change it calls your async function, debounced by debounceMs (250 ms by default), and replaces the
option list with the resolved result. In this mode options, groups and filter are ignored, since the server does the filtering. Each request receives an AbortSignal that fires the moment a newer query
supersedes it, so a slow stale response never overwrites a fresh one. The listbox shows loadingText while a request is in flight and noResultsText on zero matches. A rejection ends the
loading state, keeps the previous options in place, and is reported via onError.
Server-side search
Requests sent: 0 · Mock latency: 450 ms · Debounce: 300 ms
<script lang="ts">
import { Combobox, type ComboboxOption } from '@urbicon-ui/blocks';
let city = $state<string | null>(null);
let searchError = $state<string | undefined>(undefined);
// Forward the signal to fetch: when a newer query supersedes this request,
// the Combobox aborts it and the browser cancels the HTTP request. Aborted
// rejections are swallowed; only real failures reach onError.
async function searchCities(query: string, signal: AbortSignal): Promise<ComboboxOption[]> {
searchError = undefined;
const res = await fetch(`/api/cities?q=${encodeURIComponent(query)}`, { signal });
if (!res.ok) throw new Error(`Search failed with ${res.status}`);
const results = await res.json();
return results.map((c) => ({ label: c.name, value: c.id }));
}
</script>
<Combobox
label="City"
queryFn={searchCities}
debounceMs={300}
loadingText="Searching cities…"
bind:value={city}
error={searchError}
onError={() => (searchError = 'Search failed. Previous results are kept.')}
clearable
/>For values pre-selected before any search has run (an edit form binding value on mount), pass seedOptions so the selection renders its label instead
of the raw value. The same mock-backend pattern drives the Table's query demo. See Query Function.
04 Customization
Frosted glass
slotClasses tints the input and listbox into a glass look. It keeps the field's radius tier, spacing and keyboard behaviour. Only the fill, border and blur change, in raw colours because glass has no token equivalent.<Combobox
aria-label="Timezone"
options={timezones}
placeholder="Select your timezone…"
slotClasses={{
input:
'border-white/20 bg-white/10 text-white placeholder:text-white/60 backdrop-blur-md hover:border-white/30 focus-visible:border-white/40 focus-visible:bg-white/15',
listbox: 'border-white/20 bg-white/10 text-white backdrop-blur-xl',
option: 'text-white/80',
optionActive: 'bg-white/20 text-white',
optionSelected: 'bg-white/15 text-white',
optionCheck: 'text-white',
noResults: 'text-white/60',
chevronButton: 'text-white/60 hover:text-white'
}}
/>A field that should read as the text it sits in is the bare variant, not a stack of reset classes: no frame,
no fill, no padding, no fixed height, and size keeps
only its type step. It needs context that says it is a field — a placeholder, a rule under the
line, a label before it — and it keeps the one thing such a reset usually loses, a focus
outline, whose colour is the --blocks-focus-ring-color custom property. Worked through on the Input page.
This is one of five ways to restyle a block. See Customization for class, slotClasses, unstyled, preset and provider-level overrides.
05 Accessibility
Built-in ARIA
The input uses role="combobox" with aria-expanded, aria-controls, and aria-autocomplete="list". The listbox uses role="listbox" and each option uses role="option" with aria-selected.
Keyboard
↓ / ↑ to navigate options. Enter to select. Escape to close. Home / End to jump to first / last option. Disabled options are skipped during navigation.
Active Descendant
Focus stays on the input at all times. The visually highlighted option is communicated via aria-activedescendant, keeping screen readers
synchronized without moving DOM focus.
06 API Reference
41 propsProp | Type | Default | Description | |
|---|---|---|---|---|
allowCustom | T extends string ? boolean : never | false | Open the closed option list: whatever the user types becomes selectable as
a value of its own, with options / groups / queryFn left as the
suggestion set. A trailing row ("Use “Kino 46”", localized) appears once the
trimmed query is non-empty and no option the field knows carries that label
case-insensitively — including groups, queryFn results and the labels
behind the current selection, but not a disabled option, whose label names
nothing that can be picked. It is an ordinary option: the arrow keys reach
it, Enter picks it, and onValueChange receives the trimmed query. In
multi-select it obeys maxItems like any unselected option — at the cap it
renders disabled and Enter on it does nothing until a tag is removed. While
an async queryFn request is in flight no row is offered; it returns with
the results.
**String values only.** The selected value is the query text, so the prop is
typed away for a numeric or boolean T — Combobox<number> cannot mint a
number out of free text, and a caller who needs one parses the string
themselves in onValueChange.
The stored label is the raw query, not the row's prompt: the input (single
mode) and the tag (multi) read "Kino 46". A value picked this way therefore
needs no seedOptions entry on a later mount, and the DEV orphan warning
stays quiet for every string value while this is set. In queryFn mode a
pre-bound single value is exempt: there it is an id whose label is still
coming from the server, so it follows the async rule and needs
seedOptions until the results supply the label.
customOption is not called for this row — it renders the built-in prompt,
since the option behind it is in none of your arrays. For a row that renders
differently, or a value that is not the query text, leave allowCustom off
and append an option of your own instead. | |
class | string | — | Extra classes merged onto the root wrapper element. | |
clearable | boolean | false | Show a clear button when a value is selected. Click or press Escape to reset. | |
closeOnClickOutside | boolean | — | Whether the listbox closes on outside click. Default true.
Set to false to pin the listbox open while the consumer manages
dismissal explicitly. | |
closeOnEscape | boolean | — | Whether the listbox closes on Escape key. Default true.
Set to false for inline contexts where Escape should be intercepted
by an outer widget (e.g. a row editor that wants to revert on Escape). | |
customOption | Snippet<[ComboboxOption<T>, boolean]> | — | Custom option renderer. Receives the option and whether it is selected. | |
customTag | Snippet<[ComboboxOption<T>, () => void]> | — | Custom tag renderer replacing the default chip. Receives the selected option
and a remove callback — call it to drop the tag (fires onRemoveTag +
onValueChange). Use it to render a <Badge> or any bespoke chip.
Positional args: (option, remove). | |
debounceMs | number | 250 | Debounce applied to queryFn in milliseconds. | |
disabled | boolean | false | Disable the entire combobox. | |
error | string | — | Error message — replaces helper text, flags the field as invalid
(aria-invalid) and paints the shared danger frame on the input (single
mode) or the tokenizer control (multi). The string prop shadows the boolean
error variant axis, which the component derives from it (mirrors Input /
Select) — that is what the Omit above is for. | |
filter | (option: ComboboxOption<T>, query: string) => boolean | — | Custom filter replacing the built-in case-insensitive label match. Return true to include an option. Ignored when queryFn is set (the server filters). | |
groups | ComboboxGroup<T>[] | — | Grouped options with section labels, at parity with Select. Takes precedence
over options when set. Filtering runs per group; groups whose options all
filter out are hidden, and keyboard navigation flows across the flattened,
still-visible options exactly as it does for a flat list. | |
helper | string | — | Helper text shown below the field. Hidden when an error is set. | |
id | string | — | Deterministic HTML id for the component. Auto-generated when omitted. | |
label | string | — | Field label rendered above the input. | |
loadingText | string | 'Loading…' | Text shown in the listbox while an async queryFn request is in flight. | |
maxItems | number | — | Cap the number of selected values. Once reached, options that aren't already selected become non-selectable (and are skipped by keyboard navigation) until a tag is removed; already-selected options can still be toggled off. | |
mint | MintProp | 'none' | Micro-interaction preset applied to the search input (the
role="combobox" element). Only applies while not disabled. | |
multiple | falsetrue | — | Single-select mode (the default). Omit or set to false explicitly. | |
name | string | — | Shared name for a hidden input for native form submission. In multi-select mode one hidden input is emitted per selected value. | |
noResultsText | string | 'No results found' | Text displayed when the filter produces no matches. | |
onClickOutside | () => void | — | Fires after an outside click closes the listbox. Use for analytics
or side-effects on dismiss. Does NOT control whether the listbox
closes — that is governed by closeOnClickOutside. | |
onError | (error: unknown) => void | — | Fired when queryFn rejects (aborted / superseded requests are ignored).
The loading state ends and the previous option list stays in place — use
this to surface the failure (toast, inline message). Without a handler
the rejection is logged DEV-only (console.warn) and swallowed in
production; it never escapes as an unhandled promise rejection. | |
onEscape | () => void | — | Fires after Escape closes the listbox. Use for analytics or to clear
ephemeral state on dismiss. Does NOT control whether the listbox
closes — that is governed by closeOnEscape. | |
onOpenChange | (open: boolean) => void | — | Fires when the listbox opens or closes from user interaction (focus,
typing, chevron toggle, selection, Escape, outside click). Receives the
new open state — use it e.g. to lazy-load options on first open. Not
called when the consumer writes bind:open directly. | |
onRemoveTag | (value: T) => void | — | Fires when a single value is removed from the selection — via the tag's
remove button, Backspace on an empty query, or toggling a selected option
off. Receives the removed value. onValueChange fires alongside it with the
resulting array; use onRemoveTag for per-tag side effects (analytics, exit
animations). Note: the bulk clear button does NOT fire this per tag — it
signals through onValueChange([]) only. | |
onValueChange | (value: T | null) => void | — | Fires after the selected value changes. Receives the new value or null on clear. | |
open | boolean | false | Controls the open state of the listbox. Supports bind:open. | |
options | ComboboxOption<T>[] | — | Array of selectable options. Each needs a unique value. | |
placeholder | string | 'Search…' | Placeholder shown when the input is empty. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ Combobox: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette — presets keep hover/active/dark-mode logic coherent
and make the custom look reusable across the project. | |
query | string | — | Current search query text. Supports bind:query for external control (e.g. server-side filtering). | |
queryFn | (query: string, signal: AbortSignal) => Promise<ComboboxOption<T>[]> | — | Server-side search (analogous to the Table remote-mode API). When set, the
Combobox stops filtering client-side and instead calls queryFn — debounced
by debounceMs — on each query change, replacing the option list with
the resolved result. The AbortSignal is aborted when a newer query
supersedes an in-flight request, so a slow stale response never clobbers a
fresh one. Aborted rejections are swallowed; other rejections end the
loading state, leave the previous options in place, and are reported via
onError. options/groups are ignored in this mode. The selected
option's label is cached so it survives result sets that no longer
contain it. | |
required | boolean | false
In multi-select mode this is visual only — the transient search input
carries no native `required` (it is cleared after each pick), so enforce a
minimum selection in your submit handler. | Marks the field as required. Adds the asterisk on the label. | |
seedOptions | ComboboxOption<T>[] | — | Label seed for pre-selected values whose options are not (yet) in the
option list — the async-mode pattern of binding value on mount before
any queryFn result has arrived. Consulted as the LAST lookup source when
resolving a selected value's label (current options first, then the
pick-cache, then this seed), so it can never shadow a live option, and it
works identically for single and multi selection. Without a matching seed
such a value renders as its raw String(value) (and warns DEV-only).
Declarative and idempotent — not a second selection source: value alone
decides what is selected; seedOptions only supplies labels. A string value
under allowCustom needs no seed and raises no DEV warning — there the
value is its own label, in the input and on the tag alike. The exception is
queryFn mode, where a pre-bound single value still needs a seed until the
server supplies its label. | |
size variant | lgmdsmxl +1 more | md | Controls the dimensions, padding, and text size of the Combobox. Affects the component's physical footprint. Available options: lg, md, sm, and 2 more. | |
slotClasses | Partial<Record<ComboboxSlots, string>> | — | Per-slot class overrides merged with tv() styles. Slots: base | label | requiredMark | inputWrapper | input | message | helper | listbox | option | optionActive | optionSelected | optionHint | optionCheck | group | groupLabel | loading | noResults | clear | chevron | control | search | tag | tagLabel | tagRemove | |
tier variant | commitmodify | modify | Selects the semantic radius tier of the Combobox — the shape family it belongs to (--radius-commit/-modify/-contain/-bridge). Shape is retuned per family in your theme, so this picks the family rather than a pixel value. Available options: commit, modify. | |
unstyled | boolean | false | Remove all default tv() classes — only user-provided classes apply. | |
value | T[] | [] | Currently selected values. Supports bind:value. | |
variant | ComboboxVariants['variant'] | 'outlined' | Visual style, at parity with Input / Textarea / Select.
- outlined (default) — visible border, surface-base background
- filled — surface-interactive fill, no border
- ghost — transparent until hover/focus
- underline — bottom-line only, no border-box
- bare — no frame, no fill, no padding, no fixed height: an inline
autocomplete that reads as the text it sits in. size keeps only the
type step, and focus is an outline in --blocks-focus-ring-color |
07 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
ComboboxOption | interface | helper | 2 | A single combobox option with label, value, and optional disabled state. | |
ComboboxGroup | interface | helper | 1 | A labelled group of combobox options (parity with Select's groups). | |
ComboboxSingleProps | interface | props | 0 | Single-select arm (the default). value is T | null, and onValueChange
receives the new value or null on clear. Selecting an option closes the
listbox and mirrors the picked label back into the input. | |
ComboboxMultipleProps | interface | props | 0 | Multi-select arm. value is an array of the selected values, rendered as
removable tag chips below the search input. Selecting an option adds a tag and
keeps the listbox open so several picks flow without re-opening; the search
query is cleared after each pick. Backspace on an empty query removes the last
tag. | |
ComboboxProps | type | helper | 0 | — | |
ComboboxOptionType | type | helper | 0 | Backwards-compatible alias for ComboboxOption (legacy name). | |
ComboboxVariants | type | variant | 0 | — | |
ComboboxSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
MintProp | type | helper | 1 | — | |
SelectValue | type | helper | 0 | Primitive value types accepted by Select / Combobox options. Strings are the default; numbers and booleans cover form fields bound to numeric IDs or yes/no flags without forcing the consumer to convert back and forth at every call site. | |
MintName | type | helper | 0 | A mint name: a built-in (autocompleted), 'none' to disable, or any
consumer-registered name. (string & {}) keeps the registry open — a
custom name still type-checks, it just isn't suggested. A typo therefore
also still compiles (it resolves like an unregistered custom name and
warns at runtime); the union buys completion and docs, not validation. | |
MintConfig | interface | helper | 0 | — | |
BuiltinMintName | type | helper | 0 | Built-in mint names as a literal union, so the mint prop autocompletes
across every component — the single list the hand-curated playground knobs
and docs used to drift away from. |
08 Installation
Import
import { Combobox } from '@urbicon-ui/blocks';