Combobox
A select-only combobox: type to filter a popup list by label or alternate names, choose with the arrow keys and Enter, and post the value. Local filtering by default, or a debounced, abortable onSearch.
cmp_combobox_01 Before you use this component
- Install
bits-ui@^2.19.3. - Requires client-side JavaScript to work.
Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_combobox_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-bccd388fce0d2ae72ee4faf85181dfc6bef4ff61607e24cf436a89b982286a64
This component needs all 2 files. Download the ZIP
<!--
A select-only combobox: a text field that filters a popup list of options, where the value is
always one of the listed options. Typing narrows the list (by label, then keywords, accent- and
case-insensitive), the arrow keys move through it and Enter chooses. Text that matches no choice
never stays behind: on blur the field returns to the chosen option's label, or empties.
The popup is a Bits UI Combobox anchored to the whole field, so it opens flush under it at the
field's width and flips above when there is no room. Its options start on the same inset as the
field's text, so the typed query and its bold match in each row line up in one column. Pass
onSearch to search your own endpoint instead of filtering locally; requests are debounced and a
newer one aborts the last.
-->
<script lang="ts" module>
export type ComboboxSize = 'sm' | 'md';
</script>
<script lang="ts">
import { Combobox } from 'bits-ui';
import { onDestroy, untrack } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { filterOptions, markOptions, type ComboboxOption } from './match';
interface Props {
/** Visible label. Also names the clear button: 'Clear country'. */
label: string;
/** Every option, for local filtering. With onSearch, the options whose labels the field may need before any search runs (the initial value). */
options: ComboboxOption[];
/** The chosen option's value. Bindable; undefined when nothing is chosen. */
value?: string;
/** Searches your own endpoint instead of filtering locally. Abort your request when the signal fires. */
onSearch?: (query: string, signal: AbortSignal) => Promise<ComboboxOption[]>;
/** Name of the hidden input that posts the value (never the label) with the form. */
name?: string;
placeholder?: string;
/** Help text under the label, linked with aria-describedby. */
hint?: string;
/** Shown in the popup when nothing matches. */
emptyText?: string;
/** Milliseconds to wait after typing before onSearch runs. */
debounce?: number;
/** Native required on the hidden input, and aria-required on the field. */
required?: boolean;
/** Marker after the label of a required field. An empty string hides it. */
requiredLabel?: string;
/** Validation message from your code. Sets aria-invalid; the component never validates. */
error?: string | null;
disabled?: boolean;
/** Shows a clear button while something is chosen or typed. */
clearable?: boolean;
/** sm 32 px or md 36 px tall; both are 44 px on touch screens. */
size?: ComboboxSize;
/** Whether the popup is open. Bindable. */
open?: boolean;
/** Accessible name of the clear button. Defaults to 'Clear' and the label in lower case. */
clearLabel?: string;
/** Accessible name of the button that opens the list. */
toggleLabel?: string;
/** Shown while onSearch has not answered and there is nothing to show yet. */
loadingText?: string;
/** Shown when onSearch rejects. */
errorText?: string;
/** Announced politely once filtering settles. */
countText?: (count: number) => string;
/** Read before the error by screen readers only. */
errorPrefix?: string;
/** Input id. Defaults to one generated per instance. */
id?: string;
/** Extra classes on the outer wrapper, for placement. */
class?: ClassValue;
}
let {
label,
options,
value = $bindable(),
onSearch,
name,
placeholder,
hint,
emptyText = 'No matches',
debounce = 200,
required = false,
requiredLabel = 'Required',
error = null,
disabled = false,
clearable = true,
size = 'md',
open = $bindable(false),
clearLabel,
toggleLabel = 'Show options',
loadingText = 'Searching…',
errorText = 'Search failed. Edit the text to try again.',
countText = (count) => (count === 1 ? '1 result' : `${count} results`),
errorPrefix = 'Error:',
id,
class: className
}: Props = $props();
const uid = $props.id();
const inputId = $derived(id ?? `${uid}-input`);
const labelId = `${uid}-label`;
const hintId = `${uid}-hint`;
const errorId = `${uid}-error`;
const listboxId = `${uid}-listbox`;
const invalid = $derived(!!error);
const describedBy = $derived(
[hint && hintId, invalid && errorId].filter(Boolean).join(' ') || undefined
);
/* Results of the last search that answered, and where the next one stands. */
let results = $state<ComboboxOption[]>([]);
let searchState = $state<'idle' | 'loading' | 'error'>('idle');
let searchedQuery: string | null = null;
/* The last option chosen, so its label survives when a later search no longer lists it. */
let chosen = $state<ComboboxOption | null>(null);
/* The freshest label wins: your options, then the latest results, then the remembered choice. */
const selected = $derived(
value === undefined
? undefined
: (options.find((option) => option.value === value) ??
results.find((option) => option.value === value) ??
(chosen?.value === value ? chosen : undefined))
);
const selectedLabel = $derived(value === undefined ? '' : (selected?.label ?? value));
/* What the field shows, and what the person has typed since the popup last opened ('' = all). */
let inputValue = $state(untrack(() => selectedLabel));
let query = $state('');
let focused = $state(false);
const matches = $derived(onSearch ? markOptions(results, query) : filterOptions(options, query));
const loading = $derived(searchState === 'loading');
const showClear = $derived(clearable && !disabled && (value !== undefined || inputValue !== ''));
/*
* Keep the text on the chosen label: always while the field is not being edited, and whenever
* the value itself changes (a choice, a clear, or your code setting it).
*/
let lastValue = untrack(() => value);
$effect.pre(() => {
const current = value;
const nextLabel = selectedLabel;
untrack(() => {
const changed = current !== lastValue;
lastValue = current;
if (changed) query = '';
if (changed || !focused) inputValue = nextLabel;
});
});
/* Bits UI holds '' for no value; the prop is undefined instead. */
function getValue() {
return value ?? '';
}
function setValue(next: string) {
if (disabled) return;
if (next) chosen = matches.find((match) => match.option.value === next)?.option ?? chosen;
value = next === '' ? undefined : next;
}
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined;
/* Drops a scheduled or running search, so its answer can never land under a newer query. */
function cancelSearch() {
clearTimeout(searchTimer);
controller?.abort();
controller = undefined;
if (searchState === 'loading') searchState = 'idle';
}
/* Runs onSearch after the delay. A newer call clears the timer and aborts the request in flight. */
function search(text: string, delay: number) {
cancelSearch();
if (!onSearch) return;
const current = new AbortController();
controller = current;
searchState = 'loading';
const run = onSearch;
searchTimer = setTimeout(async () => {
try {
const found = await run(text, current.signal);
if (current.signal.aborted) return;
results = found;
searchedQuery = text;
searchState = 'idle';
} catch {
if (current.signal.aborted) return;
results = [];
searchedQuery = null;
searchState = 'error';
}
announce();
}, delay);
}
/* The polite count, set once typing pauses so a screen reader hears one number, not every keystroke. */
let status = $state('');
let announceTimer: ReturnType<typeof setTimeout> | undefined;
function announce() {
clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
if (!open) return;
// A search still running after the pause says so once; its answer replaces the message.
if (searchState === 'error') status = errorText;
else if (searchState === 'loading') status = loadingText;
else status = matches.length ? countText(matches.length) : emptyText;
}, 500);
}
/* Bits UI's inputValue is one-way, so the typed text is tracked here and flows back down. */
function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
inputValue = event.currentTarget.value;
query = inputValue;
status = '';
// Pasted or dictated text arrives without a keydown, which is what opens the popup in Bits UI.
if (!open && !disabled) open = true;
if (onSearch) search(query, debounce);
announce();
}
/*
* Runs on every real change of open, whoever made it: a keystroke, the toggle, a press in the
* field, or your code through bind:open.
*/
let wasOpen = false;
$effect.pre(() => {
const isOpen = open;
untrack(() => {
if (isOpen === wasOpen) return;
wasOpen = isOpen;
if (isOpen) {
readField();
// Opening on the chosen label (or an empty field) shows every option; text the person
// has typed keeps filtering, whichever of the keydown and the input event came first.
if (inputValue === selectedLabel) query = '';
if (onSearch && (searchedQuery !== query || loading)) search(query, query ? debounce : 0);
// An empty list or a search in progress has to be heard as well as seen; a full local
// list is not read out on every open.
if (onSearch || !matches.length) announce();
} else {
clearTimeout(announceTimer);
status = '';
}
});
});
/*
* Whether the list continues above or below what shows. The edge that continues fades out, so a
* long list says where it sits and no row is ever cut off hard against the frame.
*/
let moreAbove = $state(false);
let moreBelow = $state(false);
function readEdges(list: HTMLElement) {
moreAbove = list.scrollTop > 1;
moreBelow = list.scrollTop + list.clientHeight < list.scrollHeight - 1;
}
$effect(() => {
if (!open) return;
void matches.length;
// After Bits UI has positioned the popup and scrolled the highlighted option into view.
const frame = requestAnimationFrame(() => {
const list = document.getElementById(listboxId);
if (list) readEdges(list);
});
return () => cancelAnimationFrame(frame);
});
/* A disabled field never stays open. */
$effect.pre(() => {
if (disabled && open) open = false;
});
/* The option the input's aria-activedescendant points at, if it is still in the document. */
function activeOption(input: HTMLInputElement) {
const id = input.getAttribute('aria-activedescendant');
return id ? document.getElementById(id) : null;
}
function handleKeydown(event: KeyboardEvent & { currentTarget: HTMLInputElement }) {
const input = event.currentTarget;
// Escape closes the popup first (Bits UI), then a second press clears the field (APG).
if (event.key === 'Escape' && !open && (value !== undefined || inputValue !== '')) {
event.preventDefault();
clear();
return;
}
if (event.key === 'Enter' && open && !event.isComposing) {
const active = activeOption(input);
// Bits UI would choose a disabled option that the pointer highlighted; stop it here.
if (active?.hasAttribute('data-disabled')) {
event.preventDefault();
return;
}
// Choosing the option that is already chosen closes without a change, so put its label back.
if (active && active.getAttribute('data-value') === value) {
query = '';
inputValue = selectedLabel;
}
}
// Home and End move the caret in the text, as in any field, rather than the highlight.
if ((event.key === 'Home' || event.key === 'End') && !event.altKey && !event.metaKey) {
event.preventDefault();
const end = event.key === 'Home' ? 0 : input.value.length;
if (!event.shiftKey) {
input.setSelectionRange(end, end);
return;
}
// Shift extends from the selection's anchor, the end it started from.
const backward = input.selectionDirection === 'backward';
const anchor = (backward ? input.selectionEnd : input.selectionStart) ?? end;
input.setSelectionRange(
Math.min(anchor, end),
Math.max(anchor, end),
end < anchor ? 'backward' : 'forward'
);
}
}
/* A press in the field opens the list, as it would on a native select. */
function handleClick() {
if (!disabled && !open) open = true;
}
/* Leaving the field with text that matches no choice puts the chosen label back, or empties it. */
function handleBlur() {
focused = false;
if (query !== '' || inputValue !== selectedLabel) cancelSearch();
query = '';
inputValue = selectedLabel;
}
let input = $state<HTMLInputElement | null>(null);
function clear() {
value = undefined;
chosen = null;
inputValue = '';
query = '';
status = '';
if (onSearch && open) search('', 0);
else cancelSearch();
input?.focus();
}
/*
* The popup is portalled to the body, out of reach of tokens, direction and language set on an
* ancestor of the field. Copy the field's resolved values onto it each time it opens.
*/
const TOKENS = ['--_accent', '--_ink', '--_muted', '--_hairline', '--_surface', '--_danger'];
let field = $state<HTMLElement | null>(null);
let popupTokens = $state('');
let dir = $state<'ltr' | 'rtl'>('ltr');
let lang = $state<string | undefined>();
function readField() {
if (!field) return;
const style = getComputedStyle(field);
popupTokens = TOKENS.map((token) => `${token}: ${style.getPropertyValue(token)}`).join('; ');
dir = style.direction === 'rtl' ? 'rtl' : 'ltr';
lang = field.closest('[lang]')?.getAttribute('lang') ?? undefined;
}
onDestroy(() => {
clearTimeout(searchTimer);
clearTimeout(announceTimer);
controller?.abort();
});
const textSize = $derived(size === 'sm' ? 'text-[13px] leading-5' : 'text-sm leading-5');
</script>
<div
class={['combobox min-w-0', className]}
data-size={size}
data-invalid={invalid ? '' : undefined}
data-disabled={disabled ? '' : undefined}
>
<label
id={labelId}
for={inputId}
class={[
'block font-medium text-pretty break-words text-(--_ink)',
size === 'sm' ? 'text-[13px] leading-5' : 'text-sm leading-5'
]}
>
{label}{#if required && requiredLabel}
<!-- aria-required already says so to assistive technology. The en space is a break point,
so a marker that wraps starts on the label's edge. -->
<span class="text-[13px] font-normal text-(--_muted)" aria-hidden="true"
> {requiredLabel}</span
>{/if}
</label>
{#if hint}
<p id={hintId} class="mt-1 text-[13px] leading-5 text-pretty break-words text-(--_muted)">
{hint}
</p>
{/if}
<Combobox.Root
type="single"
bind:value={getValue, setValue}
bind:open
{inputValue}
name={name ?? ''}
{required}
{disabled}
allowDeselect={false}
scrollAlignment="center"
>
<div
bind:this={field}
class={[
'combobox__field mt-2 flex min-h-(--_h) items-stretch border border-(--_edge) bg-(--_surface)',
size === 'sm' ? 'rounded-md' : 'rounded-lg'
]}
>
<Combobox.Input
bind:ref={input}
id={inputId}
{placeholder}
autocomplete="off"
spellcheck={false}
aria-controls={listboxId}
aria-describedby={describedBy}
aria-invalid={invalid ? 'true' : undefined}
aria-required={required ? 'true' : undefined}
class={[
'combobox__input min-w-0 flex-1 bg-transparent ps-3 text-ellipsis text-(--_ink) outline-none pointer-coarse:-my-px',
'placeholder:text-(--_placeholder) disabled:cursor-not-allowed pointer-coarse:text-base',
textSize
]}
oninput={handleInput}
onkeydown={handleKeydown}
onclick={handleClick}
onfocus={() => (focused = true)}
onblur={handleBlur}
>
{#snippet child({ props })}
<!-- Bits UI keeps pointing at the last highlighted option after a search empties the
list; with nothing listed there is nothing active. -->
<input
{...props}
aria-activedescendant={matches.length
? (props['aria-activedescendant'] as string | undefined)
: undefined}
/>
{/snippet}
</Combobox.Input>
<div class="flex shrink-0 items-center gap-0.5 pe-1 pointer-coarse:gap-0 pointer-coarse:pe-0">
{#if showClear}
<button
type="button"
tabindex="-1"
aria-label={clearLabel ?? `Clear ${label.toLocaleLowerCase()}`}
class="combobox__button grid size-7 cursor-pointer place-items-center rounded-md text-(--_muted) transition-[color,background-color] duration-150 ease-(--_ease) hover:bg-(--_fill-hover) hover:text-(--_ink) active:bg-(--_fill-press) active:duration-[80ms] motion-reduce:transition-none pointer-coarse:-my-px pointer-coarse:size-11"
onpointerdown={(event) => event.preventDefault()}
onclick={clear}
>
<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="m5 5 6 6m0-6-6 6"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
</svg>
</button>
{/if}
<Combobox.Trigger
tabindex={-1}
aria-label={toggleLabel}
class="combobox__button group/toggle grid size-7 cursor-pointer place-items-center rounded-md text-(--_muted) transition-[color,background-color] duration-150 ease-(--_ease) hover:bg-(--_fill-hover) hover:text-(--_ink) active:bg-(--_fill-press) active:duration-[80ms] disabled:cursor-not-allowed motion-reduce:transition-none pointer-coarse:-my-px pointer-coarse:size-11"
>
{#if loading && matches.length > 0}
<svg
class="size-4 animate-spin motion-reduce:animate-pulse"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6.25"
stroke="currentColor"
stroke-opacity="0.25"
stroke-width="1.75"
/>
<path
d="M14.25 8A6.25 6.25 0 0 0 8 1.75"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
</svg>
{:else}
<svg
class="size-4 transition-transform duration-200 ease-(--_ease) group-data-[state=open]/toggle:rotate-180 motion-reduce:transition-none"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="m4.75 6.5 3.25 3.25 3.25-3.25"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</Combobox.Trigger>
</div>
</div>
<Combobox.Portal>
<Combobox.Content
customAnchor={field}
side="bottom"
align="start"
sideOffset={6}
collisionPadding={8}
style={popupTokens}
>
{#snippet child({ props, wrapperProps })}
<div {...wrapperProps}>
<!-- The popup is only a frame: the listbox is the viewport inside it, so the status
line below the options is never read as one of them. A press anywhere in the
frame keeps focus in the field, as presses on the options do. -->
<div
{...props}
role={undefined}
{dir}
{lang}
onmousedown={(event) => event.preventDefault()}
class={[
'combobox__popup flex w-(--bits-combobox-anchor-width) flex-col overflow-hidden rounded-xl border border-(--_popup-edge)',
'max-h-[min(22rem,var(--bits-combobox-content-available-height))] bg-(--_surface) text-(--_ink) shadow-(--_shadow-popup) outline-none'
]}
>
<Combobox.Viewport
class={[
'combobox__viewport min-h-0 flex-1 overscroll-contain',
matches.length && 'p-1'
]}
>
{#snippet child({ props: viewportProps })}
<div
{...viewportProps}
id={listboxId}
role="listbox"
aria-labelledby={labelId}
aria-busy={loading ? 'true' : undefined}
data-more-above={moreAbove ? '' : undefined}
data-more-below={moreBelow ? '' : undefined}
onscroll={(event) => readEdges(event.currentTarget)}
>
{#each matches as match (match.option.value)}
<Combobox.Item
value={match.option.value}
label={match.option.label}
disabled={match.option.disabled}
aria-disabled={match.option.disabled ? 'true' : undefined}
class={[
'combobox__option group/option flex min-h-9 cursor-pointer items-start gap-2 rounded-lg py-2 ps-2 pe-2 select-none pointer-coarse:min-h-11 pointer-coarse:py-3',
'data-disabled:cursor-not-allowed data-highlighted:bg-(--_fill-hover)',
textSize
]}
>
{#snippet children({ selected: isSelected })}
<span class="min-w-0 flex-1 group-data-disabled/option:opacity-50">
<span class="block text-pretty break-words"
>{#each match.label as segment, i (i)}{#if segment.match}<mark
class="bg-transparent font-semibold text-inherit"
>{segment.text}</mark
>{:else}{segment.text}{/if}{/each}{#if match.keyword}<span
class="text-(--_muted)"
> · {#each match.keyword as segment, i (i)}{#if segment.match}<mark
class="bg-transparent font-semibold text-(--_ink)"
>{segment.text}</mark
>{:else}{segment.text}{/if}{/each}</span
>{/if}</span
>
{#if match.option.description}
<span
class="block text-[13px] leading-5 text-pretty break-words text-(--_muted)"
>{match.option.description}</span
>
{/if}
</span>
<span
class="grid h-5 w-4 shrink-0 place-items-center text-(--_accent)"
aria-hidden="true"
>
{#if isSelected}
<svg class="size-4" viewBox="0 0 16 16" fill="none">
<path
d="m3.75 8.25 2.75 2.75 5.75-6"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</span>
{/snippet}
</Combobox.Item>
{/each}
</div>
{/snippet}
</Combobox.Viewport>
{#if !matches.length}
<p
class={[
'flex items-start gap-2 py-2 ps-3 pe-3',
searchState === 'error' ? 'text-(--_danger)' : 'text-(--_muted)',
textSize
]}
>
<span class="min-w-0 flex-1 text-pretty break-words">
{searchState === 'error' ? errorText : loading ? loadingText : emptyText}
</span>
{#if searchState === 'error'}
<span class="grid h-5 w-4 shrink-0 place-items-center" aria-hidden="true">
<svg class="size-4" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
<path
d="M8 4.75v3.5"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
<circle cx="8" cy="11" r="1" fill="currentColor" />
</svg>
</span>
{:else if loading}
<svg
class="size-4 h-5 shrink-0 animate-spin motion-reduce:animate-pulse"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6.25"
stroke="currentColor"
stroke-opacity="0.25"
stroke-width="1.75"
/>
<path
d="M14.25 8A6.25 6.25 0 0 0 8 1.75"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
</svg>
{/if}
</p>
{/if}
</div>
</div>
{/snippet}
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
{#if invalid}
<p id={errorId} class="mt-1 flex items-start gap-1 text-[13px] leading-5 text-(--_danger)">
<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
<svg class="size-3.5" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
<path d="M8 4.75v3.5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
<circle cx="8" cy="11" r="1" fill="currentColor" />
</svg>
</span>
<span class="min-w-0 text-pretty break-words"
><span class="sr-only">{`${errorPrefix} `}</span>{error}</span
>
</p>
{/if}
<span class="sr-only" role="status" aria-live="polite" aria-atomic="true">{status}</span>
</div>
<style>
/*
* Public tokens: set --combobox-* on the field or any ancestor to retone it. The popup is
* portalled, so it declares the same defaults and receives the field's resolved values inline.
*/
.combobox,
:global(.combobox__popup) {
--_accent: var(--combobox-accent, #18181b);
--_ink: var(--combobox-ink, #18181b);
--_muted: var(--combobox-muted, #52525b);
--_placeholder: var(--combobox-placeholder, #71717a);
/* 3:1 against white: the edge is the only thing marking an empty field (WCAG 1.4.11). */
--_hairline: var(--combobox-hairline, rgb(0 0 0 / 0.44));
--_surface: var(--combobox-surface, #ffffff);
--_danger: var(--combobox-danger, #b91c1c);
--_ease: cubic-bezier(0.2, 0, 0, 1);
/* Formulas: hover and highlight fills, the popup's quieter edge and its layered shadow. */
--_fill-hover: color-mix(in oklab, var(--_surface), var(--_ink) 6%);
--_fill-press: color-mix(in oklab, var(--_surface), var(--_ink) 11%);
--_popup-edge: color-mix(in oklab, var(--_hairline) 30%, transparent);
--_shadow-popup: 0 4px 6px -1px rgb(0 0 0 / 0.07), 0 10px 15px -3px rgb(0 0 0 / 0.05);
}
/* The field's height per size; touch screens get 44 px. */
.combobox {
--_h: 2.25rem;
--_edge: var(--_hairline);
--_ring: var(--_accent);
}
.combobox[data-size='sm'] {
--_h: 2rem;
}
@media (pointer: coarse) {
.combobox[data-size] {
--_h: 2.75rem;
}
}
.combobox[data-invalid] {
--_edge: var(--_danger);
--_ring: var(--_danger);
}
/*
* The edge and halo follow the input inside the field, so the transition that animates them
* lives here with the rules that change them.
*/
.combobox__field {
transition-property: border-color, box-shadow;
transition-duration: 150ms;
transition-timing-function: var(--_ease);
}
@media (hover: hover) {
.combobox:not([data-disabled]) .combobox__field:hover {
--_edge: color-mix(in oklab, var(--_hairline), var(--_ink) 25%);
}
.combobox[data-invalid] .combobox__field:hover {
--_edge: var(--_danger);
}
}
/*
* Focus: a two-pixel edge in the accent (border plus one ring) and a soft halo outside. The input
* is rendered by Bits UI, so the selector reaches it through :global or Svelte would prune it.
*/
.combobox__field:has(:global(input:focus-visible)) {
border-color: var(--_ring);
box-shadow:
0 0 0 1px var(--_ring),
0 0 0 4px color-mix(in oklab, var(--_ring) 14%, transparent);
}
.combobox[data-disabled] .combobox__field {
background-color: color-mix(in oklab, var(--_surface), var(--_ink) 4%);
opacity: 0.55;
}
/*
* Bits UI hides the viewport's scrollbar; a list of hundreds needs one, so a thin one comes back.
* The standard property wins over the WebKit pseudo-element where both are supported.
*/
:global(.combobox__viewport[data-combobox-viewport]) {
scrollbar-width: thin !important;
scrollbar-color: color-mix(in oklab, var(--_ink) 25%, transparent) transparent;
}
/* The edge where the list continues fades over 20 px (about half a row). */
:global(.combobox__viewport[data-more-above]),
:global(.combobox__viewport[data-more-below]) {
--_fade-top: 0px;
--_fade-bottom: 0px;
mask-image: linear-gradient(
to bottom,
transparent,
#000 var(--_fade-top),
#000 calc(100% - var(--_fade-bottom)),
transparent
);
}
:global(.combobox__viewport[data-more-above]) {
--_fade-top: 1.25rem;
}
:global(.combobox__viewport[data-more-below]) {
--_fade-bottom: 1.25rem;
}
/* Open and close: a short fade and drop from the field, faster out than in. */
:global(.combobox__popup[data-state='open']) {
animation: combobox-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
:global(.combobox__popup[data-state='closed']) {
animation: combobox-out 150ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes -global-combobox-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@keyframes -global-combobox-out {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
:global(.combobox__popup[data-state='open']) {
animation-name: combobox-fade;
}
}
@keyframes -global-combobox-fade {
from {
opacity: 0;
}
}
/* Forced colours drop shadows and fills, so focus and the highlighted option get system colours. */
@media (forced-colors: active) {
.combobox__field:has(:global(input:focus-visible)) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
:global(.combobox__option[data-highlighted]) {
outline: 2px solid Highlight;
outline-offset: -2px;
}
}
</style>
Usage#
On this pageGive it a label and a list of { value, label } options and it renders a field that filters them as you type; bind value to read the choice, and pass name to post it with a form. Pass onSearch to query your own endpoint instead. It only ever selects a listed option (no free text), never fetches anything without onSearch, and does not rank, cache or rate-limit your search.
- Suggested location
src/lib/components/combobox-01
Limitations
- Select-only: text that matches no option is never kept. Use an autocomplete input when free text is allowed.
- required is enforced through the hidden input, which renders only when name is set. Without a name, required only sets
aria-required; validate the value yourself. - Every option is rendered while the popup is open. A few hundred is fine (the long-list fixture has all 418 time zones); for thousands, use
onSearchand return a page of results. onSearchreplaces local filtering entirely: your endpoint decides ranking, accents and keywords. The component only marks the typed text in the labels you return. It is called with '' when the popup opens, so return suggestions or an empty list for that.- With
onSearch, the label of an initial value comes from options; pass the chosen option there, or the field shows the raw value until a search returns it. - Needs JavaScript to filter and choose. Server-rendered, the field shows the chosen label and the hidden input posts its value.
- The popup is portalled to the body; tokens, direction and language are copied from the field each time it opens, so a retone made while it is open applies on the next open.
- Options are a flat list: no groups or headings.
Example
<script lang="ts">
import Combobox from '$lib/components/combobox-01/Combobox.svelte';
import type { ComboboxOption } from '$lib/components/combobox-01/match';
const countries: ComboboxOption[] = [
{ value: 'DE', label: 'Germany', keywords: ['Deutschland'] },
{ value: 'NZ', label: 'New Zealand', keywords: ['Aotearoa'] },
{ value: 'SE', label: 'Sweden', keywords: ['Sverige'] }
];
let country = $state<string | undefined>('NZ');
</script>
<form method="POST" class="max-w-sm">
<Combobox
label="Billing country"
name="country"
options={countries}
bind:value={country}
required
hint="Sets the tax on your invoices."
/>
</form>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Combobox#
A select-only combobox. The person types to narrow a list and must choose one of the listed options; the field never keeps text that is not a choice. On blur, unmatched text returns to the chosen option's label, or empties when nothing is chosen.
Local filtering#
Matching ignores case and accents and checks the label first, then keywords. Results are
ranked: a match at the start of the label, then at the start of a later word, then inside a
word, then keyword matches. Ties keep your order, so pass options in the order you want an empty
query to show them.
<Combobox label="Billing country" name="country" {options} bind:value={country} />Searching your own endpoint#
<script lang="ts">
import Combobox from '$lib/components/combobox-01/Combobox.svelte';
import type { ComboboxOption } from '$lib/components/combobox-01/match';
let assignee = $state<string | undefined>();
async function searchMembers(query: string, signal: AbortSignal): Promise<ComboboxOption[]> {
const response = await fetch(`/api/members?q=${encodeURIComponent(query)}`, { signal });
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
return response.json();
}
</script>
<Combobox
label="Assignee"
name="assignee"
options={[]}
onSearch={searchMembers}
bind:value={assignee}
/>onSearchrunsdebouncems (default 200) after typing stops, and immediately with''when the popup opens. A newer query aborts the previous signal; an answer that arrives after its signal aborted is ignored.- Throw or reject to show
errorText. Aborts are not errors. - Your endpoint owns ranking, rate limits and paging. Return a page, not the whole table.
- For an initial value, put that option in
optionsso the field can show its label.
Form posting#
name renders a hidden input holding the value. required sets native required on it, so
the browser blocks submission with nothing chosen. Labels are never posted.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | Visible label, always shown. Also names the clear button. |
options | ComboboxOption[] | Yes | None | { value, label, keywords?, description?, disabled? }. Every option for local filtering; with onSearch, the options the field may need a label for before any search runs, usually just the initial value, or []. |
value | string | undefined | No | None | The chosen option's value. Bindable; undefined when nothing is chosen. |
onSearch | (query: string, signal: AbortSignal) => Promise<ComboboxOption[]> | No | None | Search your own endpoint instead of filtering locally. Debounced; a newer query aborts the signal of the last one, and only the latest answer is shown. A rejection shows errorText. |
name | string | No | None | Name of the hidden input that posts the value (never the label) with the form. |
placeholder | string | No | None | Placeholder in the empty field. Never a substitute for the label. |
hint | string | No | None | Help text under the label, first in aria-describedby. |
emptyText | string | No | 'No matches' | Shown in the popup and announced when nothing matches. |
debounce | number | No | 200 | Milliseconds after the last keystroke before onSearch runs. |
required | boolean | No | false | Native required on the hidden input (needs name), and aria-required on the field. |
requiredLabel | string | No | 'Required' | Muted marker after the label of a required field, hidden from assistive technology (aria-required already says it). Translate it, or pass '' to hide it when every field on the form is required. |
error | string | null | No | null | Validation message from your code: danger edge, icon and message, aria-invalid. The component never validates. |
disabled | boolean | No | false | Disables the field and its buttons; the hidden input posts nothing. |
clearable | boolean | No | true | Shows a clear button while something is chosen or typed. Escape clears either way. |
size | 'sm' | 'md' | No | 'md' | 32 or 36 px tall with 13 or 14 px text; both are 44 px with 16 px text on touch screens. |
open | boolean | No | false | Whether the popup is open. Bindable. |
clearLabel | string | No | None | Accessible name of the clear button. Defaults to 'Clear' and the label in lower case ('Clear billing country'); set it for languages with capitalised nouns. |
toggleLabel | string | No | 'Show options' | Accessible name of the button that opens the list. |
loadingText | string | No | 'Searching…' | Shown while onSearch has not answered and there is nothing to show yet. |
errorText | string | No | 'Search failed. Edit the text to try again.' | Shown and announced when onSearch rejects. |
countText | (count: number) => string | No | '1 result' / 'N results' | The polite announcement once filtering settles. Translate it with the page. |
errorPrefix | string | No | 'Error:' | Visually hidden text read before the error. |
id | string | No | None | Input id. Defaults to one generated per instance; the other ids are always generated. |
class | ClassValue | No | None | Extra classes on the outer wrapper, for placement and width. |
Customization#
On this pageChange copy through props and retone the field and its popup through eight --combobox-* CSS variables. Hover, highlight and the popup edge are mixed from those, so they follow any retone.
- Accent:
--combobox-accentcolours the focus edge and its halo and the check on the chosen option. Keep it at 3:1 against the page. - Neutrals:
--combobox-inkis the label, value and options;--combobox-mutedthe hint, buttons, keyword and descriptions;--combobox-placeholderthe placeholder;--combobox-hairlinethe field's resting edge (keep it at 3:1, it is the only thing marking an empty field; the popup edge is a 30% mix of it);--combobox-surfacethe field and popup fill. - Danger:
--combobox-dangercolours the error edge, ring, icon and message, and the search-failed row. - Worked retone for a
#09090bband:--combobox-accent:#fafafa;--combobox-ink:#fafafa;--combobox-muted:#a1a1aa;--combobox-placeholder:#a1a1aa;--combobox-hairline: rgb(255 255 255 / 0.35);--combobox-surface:#18181b;--combobox-danger:#f87171. The popup shadow is black and will not show on a dark page, so the popup edge carries the boundary. - Cream page such as a bakery's
#fbf7f0: leave the hairline alone (alpha black tints with the page) and set--combobox-surface:#fffdf9. - Options: put other names people type in keywords (local names, codes, old names); put one short fact in description (a role, opening days). Keep labels the thing people read on the receipt.
- Search: with
onSearch, pass the signal to fetch so aborted requests stop; return at most a page of results; return suggestions (recent, popular) for ''. - Sizes: sm for toolbars and dense filters beside 32 px buttons, md for forms. Set clearable false where a value is always required and a clear would only empty it.
- Copy: translate
emptyText,loadingText,errorText,countText,clearLabel,toggleLabelanderrorPrefixwith the page.
Public CSS variables
| Variable | Token |
|---|---|
--combobox-accent | accent |
--combobox-ink | ink |
--combobox-muted | muted |
--combobox-placeholder | placeholder |
--combobox-hairline | hairline |
--combobox-surface | surface |
--combobox-danger | danger |
Dependencies and services#
On this page| Package | Range | Resolved at build time | Purpose |
|---|---|---|---|
bits-ui | ^2.19.3 | 2.19.3 | Headless Combobox: input and listbox coordination, aria-activedescendant, arrow-key highlight, pointer selection, outside-press and Escape dismissal, the hidden form input, portal and collision-aware positioning. |
Install with (shown for reference, run it yourself)
npm install bits-ui@^2.19.3 Accessibility#
On this page- WAI-ARIA APG combobox with list autocomplete: the input has role combobox,
aria-autocompletelist,aria-expanded,aria-controlsandaria-activedescendant; focus never leaves the input. Options are role option witharia-selected. - Keys: typing opens and filters; ArrowDown and ArrowUp open the list or move the highlight; Enter chooses the highlighted option and closes; Escape closes, and a second Escape clears the field and the value; Home and End move the caret; Tab leaves and closes.
- The result count ('7 results', or the empty text) is announced through a polite status region half a second after typing pauses, not on every keystroke. A failed search announces
errorText. - While only a status line shows (no matches, searching, failed), the popup drops the listbox role so it never reads as an empty list.
- The clear and toggle buttons are named ('Clear billing country', 'Show options') and kept out of the tab order, so the field is one tab stop; Escape clears from the keyboard.
- A native <label for> names the input. Hint then error are linked through
aria-describedby;aria-invalidis set only while error is set, and the error has an icon and a hiddenerrorPrefix, never colour alone. - Disabled options stay listed, dimmed, and the highlight skips them.
- Text is 16 px and the field and option rows are 44 px on touch screens. Under reduced motion the popup fades without moving; in forced-colours mode focus and the highlighted option use system Highlight outlines.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.