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 "blue", 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
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-0c958562fd6381fccdd3fca69871211109e43e8133e72e7a180eee2e0e5328f1
This component needs all 2 files. Download the ZIP
/**
* 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)
}));
}
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.