Skip to content

Before you use this component

  • Install bits-ui@^2.19.3.
  • Requires client-side JavaScript to work.
Download ZIP

Neutral palette · 15.7 KB ZIP File receipt View as Markdown View code

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.
cmp_combobox_01 · version 1.0.0 · Neutral palette
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

match.ts TypeScript · 5.2 KB Raw
/**
 * Local filtering for the combobox: accent- and case-insensitive matching on an option's label,
 * then its keywords, ranked so a match at the start of the label comes first, then one at the
 * start of a word, then one inside a word. Each result carries the label split into matched and
 * unmatched runs, so the popup can set the typed text in a heavier weight.
 */

export interface ComboboxOption {
	/** What the form posts and what `value` holds. Unique within the list. */
	value: string;
	/** What the person reads and what the field shows once chosen. */
	label: string;
	/** Other names that should find this option: 'Deutschland' for Germany, 'NZ' for New Zealand. */
	keywords?: string[];
	/** One short muted line under the label: a role, a region, opening hours. */
	description?: string;
	/** Listed but not selectable. */
	disabled?: boolean;
}

export interface ComboboxSegment {
	text: string;
	match: boolean;
}

export interface ComboboxMatch {
	option: ComboboxOption;
	/** The label in matched and unmatched runs. */
	label: ComboboxSegment[];
	/** Set when only a keyword matched: that keyword, split the same way, shown beside the label. */
	keyword?: ComboboxSegment[];
}

interface Folded {
	text: string;
	/** For each folded code unit, the index of the original character it came from, plus the end. */
	map: number[];
}

/** Lower-cases and strips accents character by character, keeping a map back to the original. */
function fold(source: string): Folded {
	let text = '';
	const map: number[] = [];
	let index = 0;
	for (const char of source) {
		const folded = char
			.normalize('NFD')
			.replace(/\p{M}/gu, '')
			.toLocaleLowerCase()
			// Final sigma folds to the medial form, so ΟΣ and ος match.
			.replace(/ς/g, 'σ');
		for (let i = 0; i < folded.length; i++) map.push(index);
		text += folded;
		index += char.length;
	}
	map.push(source.length);
	return { text, map };
}

/** The query as it is compared: folded, trimmed, inner whitespace collapsed. */
export function normalizeQuery(query: string): string {
	return fold(query.trim().replace(/\s+/g, ' ')).text;
}

const WORD_CHAR = /[\p{L}\p{N}]/u;

/**
 * The best occurrence of the query in a text: rank 0 at its start, 1 at the start of a later
 * word, 2 inside a word. Returns null when the text does not contain the query.
 */
function find(source: string, query: string): { rank: number; start: number; end: number } | null {
	const { text, map } = fold(source);
	let best: { rank: number; start: number; end: number } | null = null;
	for (let at = text.indexOf(query); at !== -1; at = text.indexOf(query, at + 1)) {
		// The whole character before the match, so a letter outside the BMP is not read as half a pair.
		const before = at === 0 ? '' : ([...text.slice(Math.max(0, at - 2), at)].at(-1) ?? '');
		const rank = at === 0 ? 0 : WORD_CHAR.test(before) ? 2 : 1;
		if (!best || rank < best.rank) {
			const start = map[at];
			const end = Math.max(map[at + query.length] ?? source.length, start + 1);
			best = { rank, start, end };
			if (rank === 0) break;
		}
	}
	return best;
}

function split(source: string, range?: { start: number; end: number } | null): ComboboxSegment[] {
	if (!range) return [{ text: source, match: false }];
	return [
		{ text: source.slice(0, range.start), match: false },
		{ text: source.slice(range.start, range.end), match: true },
		{ text: source.slice(range.end), match: false }
	].filter((segment) => segment.text);
}

/** Every option, unranked and unmarked, for an empty query. */
export function allOptions(options: ComboboxOption[]): ComboboxMatch[] {
	return options.map((option) => ({ option, label: split(option.label) }));
}

/**
 * The options that match the query, best first. Label matches outrank keyword matches; ties keep
 * the order the options were given in.
 */
export function filterOptions(options: ComboboxOption[], query: string): ComboboxMatch[] {
	const needle = normalizeQuery(query);
	if (!needle) return allOptions(options);

	const ranked: { rank: number; match: ComboboxMatch }[] = [];
	for (const option of options) {
		const inLabel = find(option.label, needle);
		if (inLabel) {
			ranked.push({ rank: inLabel.rank, match: { option, label: split(option.label, inLabel) } });
			continue;
		}
		// The keyword with the strongest match speaks for the option, not merely the first one.
		let best: { rank: number; keyword: string; range: { start: number; end: number } } | null =
			null;
		for (const keyword of option.keywords ?? []) {
			const inKeyword = find(keyword, needle);
			if (inKeyword && (!best || inKeyword.rank < best.rank))
				best = { rank: inKeyword.rank, keyword, range: inKeyword };
		}
		if (best) {
			ranked.push({
				rank: 3 + Math.min(best.rank, 1),
				match: { option, label: split(option.label), keyword: split(best.keyword, best.range) }
			});
		}
	}
	// Array.prototype.sort is stable, so equal ranks keep the given order.
	return ranked.sort((a, b) => a.rank - b.rank).map((entry) => entry.match);
}

/** Marks the query inside options that came back from a search, without filtering or reordering. */
export function markOptions(options: ComboboxOption[], query: string): ComboboxMatch[] {
	const needle = normalizeQuery(query);
	return options.map((option) => ({
		option,
		label: split(option.label, needle ? find(option.label, needle) : null)
	}));
}

Give 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
Required props
labeloptions

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 onSearch and return a page of results.
  • onSearch replaces 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

Svelte
<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.

Svelte
<Combobox label="Billing country" name="country" {options} bind:value={country} />

Searching your own endpoint#

Svelte
<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}
/>
  • onSearch runs debounce ms (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 options so 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
NameTypeRequiredDefaultDescription
labelstringYesNoneVisible label, always shown. Also names the clear button.
optionsComboboxOption[]YesNone{ 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 [].
valuestring | undefinedNoNoneThe chosen option's value. Bindable; undefined when nothing is chosen.
onSearch(query: string, signal: AbortSignal) => Promise<ComboboxOption[]>NoNoneSearch 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.
namestringNoNoneName of the hidden input that posts the value (never the label) with the form.
placeholderstringNoNonePlaceholder in the empty field. Never a substitute for the label.
hintstringNoNoneHelp text under the label, first in aria-describedby.
emptyTextstringNo'No matches'Shown in the popup and announced when nothing matches.
debouncenumberNo200Milliseconds after the last keystroke before onSearch runs.
requiredbooleanNofalseNative required on the hidden input (needs name), and aria-required on the field.
requiredLabelstringNo'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.
errorstring | nullNonullValidation message from your code: danger edge, icon and message, aria-invalid. The component never validates.
disabledbooleanNofalseDisables the field and its buttons; the hidden input posts nothing.
clearablebooleanNotrueShows 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.
openbooleanNofalseWhether the popup is open. Bindable.
clearLabelstringNoNoneAccessible name of the clear button. Defaults to 'Clear' and the label in lower case ('Clear billing country'); set it for languages with capitalised nouns.
toggleLabelstringNo'Show options'Accessible name of the button that opens the list.
loadingTextstringNo'Searching…'Shown while onSearch has not answered and there is nothing to show yet.
errorTextstringNo'Search failed. Edit the text to try again.'Shown and announced when onSearch rejects.
countText(count: number) => stringNo'1 result' / 'N results'The polite announcement once filtering settles. Translate it with the page.
errorPrefixstringNo'Error:'Visually hidden text read before the error.
idstringNoNoneInput id. Defaults to one generated per instance; the other ids are always generated.
classClassValueNoNoneExtra classes on the outer wrapper, for placement and width.

Customization#

On this page

Change 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-accent colours the focus edge and its halo and the check on the chosen option. Keep it at 3:1 against the page.
  • Neutrals: --combobox-ink is the label, value and options; --combobox-muted the hint, buttons, keyword and descriptions; --combobox-placeholder the placeholder; --combobox-hairline the 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-surface the field and popup fill.
  • Danger: --combobox-danger colours the error edge, ring, icon and message, and the search-failed row.
  • Worked retone for a #09090b band: --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, toggleLabel and errorPrefix with the page.

Public CSS variables

VariableToken
--combobox-accentaccent
--combobox-inkink
--combobox-mutedmuted
--combobox-placeholderplaceholder
--combobox-hairlinehairline
--combobox-surfacesurface
--combobox-dangerdanger

Dependencies and services#

On this page
PackageRangeResolved at build timePurpose
bits-ui^2.19.32.19.3Headless 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-autocomplete list, aria-expanded, aria-controls and aria-activedescendant; focus never leaves the input. Options are role option with aria-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-invalid is set only while error is set, and the error has an icon and a hidden errorPrefix, 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.