Combobox
Searchable autocomplete input with keyboard navigation and custom filtering.
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
Multi-select with tags
multiple to bind an array of values. Picks render as removable tag chips below the search input, the listbox stays open across selections, Backspace on an empty field removes the last tag, and maxItems caps the count — non-selected options grey out once the cap is reached.<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 for 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 — avatars, badges, secondary descriptions, status indicators.<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" />
<div class="flex flex-1 items-center gap-2 truncate">
<span class="truncate text-sm">{opt.label.split(' — ')[0]}</span>
<Badge size="xs" variant="soft" intent={isSelected ? 'success' : 'neutral'}>
{opt.label.split(' — ')[1]}
</Badge>
</div>
{#if isSelected}
<CheckIcon size={14} class="text-primary" />
{/if}
</div>
{/snippet}
</Combobox>02 Async Search
Pass queryFn and the Combobox stops filtering
client-side: on each query change it calls your async function — debounced by debounceMs (default 250 ms) — and replaces the
option list with the resolved result. options, groups,
and filter are ignored in this mode — the server does
the filtering. Requests run only while the listbox is open, and each request receives an AbortSignal that is aborted the moment a newer query
supersedes it, so a slow stale response never clobbers a fresh one. While a request is in
flight the listbox shows loadingText; zero matches
render noResultsText. A rejection ends the loading
state, keeps the previous options in place, and is reported via onError.
Server-side search
onError.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 that are 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.
03 Customization
Command Palette
<Combobox
options={[
{ label: '⌘K Open Command Palette', value: 'cmd-k' },
{ label: '⌘P Quick Open File', value: 'cmd-p' },
{ label: '⌘⇧P Show All Commands', value: 'cmd-shift-p' },
{ label: '⌘B Toggle Sidebar', value: 'cmd-b' },
{ label: '⌘J Toggle Terminal', value: 'cmd-j' },
{ label: '⌘, Open Settings', value: 'cmd-comma' }
]}
aria-label="Command palette"
placeholder="Type a command…"
size="lg"
slotClasses={{
base: 'w-full',
input:
'rounded-xl shadow-[var(--blocks-shadow-lg)] ring-2 ring-primary/20 focus-visible:ring-primary/50 transition-all',
listbox: 'rounded-xl shadow-[var(--blocks-shadow-lg)]'
}}
/>Glassmorphism
<Combobox
aria-label="Timezone"
options={timezones}
placeholder="Select your timezone…"
unstyled
slotClasses={{
base: 'relative w-full',
input:
'w-full rounded-xl border border-white/20 bg-white/10 px-5 py-3 text-white placeholder-white/50 shadow-lg backdrop-blur-md transition-all focus-visible:border-white/40 focus-visible:bg-white/15 focus-visible:outline-none',
listbox:
'absolute z-[var(--z-dropdown)] mt-2 w-full rounded-xl border border-white/20 bg-white/10 p-1 shadow-xl backdrop-blur-xl max-h-60 overflow-y-auto',
option:
'flex w-full items-center gap-2 rounded-lg px-4 py-2.5 text-white/80 cursor-pointer transition-colors hover:bg-white/15',
optionActive: 'bg-white/20 text-white',
optionSelected: 'text-white font-medium',
noResults: 'px-4 py-3 text-center text-white/50 text-sm',
chevron:
'absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40 pointer-events-none'
}}
/>Terminal / Monospace
<Combobox
aria-label="Language"
options={languages}
placeholder="$ select --lang"
unstyled
slotClasses={{
base: 'relative w-full font-mono',
input:
'w-full bg-neutral-950 text-green-400 border-2 border-green-600/50 rounded-none px-4 py-3 text-sm placeholder:text-green-600/50 focus-visible:outline-none focus-visible:border-green-400',
listbox:
'absolute z-[var(--z-dropdown)] mt-0 w-full bg-neutral-950 border-2 border-t-0 border-green-600/50 max-h-60 overflow-y-auto',
option:
'flex w-full items-center gap-2 px-4 py-2 text-sm text-green-300 cursor-pointer hover:bg-green-900/30',
optionActive: 'bg-green-800/40 text-green-200',
optionSelected: 'text-green-100 font-bold',
noResults: 'px-4 py-3 text-center text-green-700 text-sm',
chevron:
'absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-green-600/50 pointer-events-none'
}}
/>A search-field skin used in more than one place — command palette, hero search — is better
registered as a BlocksProvider preset (presets.Combobox) than repeated slotClasses. See Customization.
04 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.
05 API Reference
Prop | Type | Default | Description | |
|---|---|---|---|---|
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. | |
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 | 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 variant | filledghostoutlinedunderline | outlined | Controls the visual style and presentation of the Combobox. Determines the component's visual treatment. Available options: filled, ghost, outlined, underline. |
06 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. |
07 Installation
Import
import { Combobox } from '@urbicon-ui/blocks';