Combobox
A select with a search box, filtering the way accented text is actually typed.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-combobox.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-combobox.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-combobox.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-combobox.jsonshadcn builds the combobox out of a popover and a command menu, and its docs are explicit that this
is a recipe rather than a component: around fifty lines of markup, a triggerRef, and a closeAndFocusTrigger that has to be written by hand every time.
Hand-rolled, that recipe gets pasted rather than read, and the parts it leaves out (accent handling first) are the ones that quietly go missing. That is the pattern this component ends.
<script lang="ts">
import Combobox from '#lib/components/coral/kit/combobox/combobox.svelte';
const fruits = [
{ value: 'acai', label: 'Açaí' },
{ value: 'mango', label: 'Mango' },
{ value: 'kiwi', label: 'Kiwi' },
{ value: 'guava', label: 'Guava' },
{ value: 'lychee', label: 'Lychee' }
];
let fruit = $state<string>();
</script>
<div class="w-64">
<Combobox
options={fruits}
bind:value={fruit}
placeholder="Select a fruit..."
searchPlaceholder="Search fruits..."
emptyMessage="No fruit found."
/>
</div>What Coral adds
Only two things, both behavior:
- Accent-insensitive search: typing
acaifindsAçaí. Command's own matcher compares raw strings, so it finds nothing. - Focus returns to the trigger after a selection, so the next Tab continues through the form instead of restarting at the top of the document.
Everything else is shadcn's, unchanged.
Search
<script lang="ts">
import Combobox from '#lib/components/coral/kit/combobox/combobox.svelte';
const fruits = [
{ value: 'acai', label: 'Açaí' },
{ value: 'pina', label: 'Piña' },
{ value: 'guarana', label: 'Guaraná' },
{ value: 'maracuja', label: 'Maracujá' },
{ value: 'lychee', label: 'Lychee' },
{ value: 'kiwi', label: 'Kiwi' },
{ value: 'papaya', label: 'Papaya' }
];
let fruit = $state<string>();
</script>
<div class="w-64">
<!-- Type `acai`, `pina` or `guarana` - unaccented, as anyone actually types it. -->
<Combobox
options={fruits}
bind:value={fruit}
placeholder="Select a fruit..."
searchPlaceholder="Try typing acai..."
emptyMessage="No fruit found."
/>
</div>Both the search term and the label are folded before comparison: lower case, accents removed, ñ to n. It matches how people type, not how the word is spelled.
Without the folding the control looks correct until someone types acai and the list comes back
empty. That failure only ever shows up for the users whose words carry accents.
Import
<script lang="ts">
import Combobox from '#lib/components/coral/kit/combobox/combobox.svelte';
</script>Props
Everything the shadcn popover root accepts stays available: open, onOpenChange, onOpenChangeComplete. On top of that:
| Prop | Type | Default | Description |
|---|---|---|---|
options | Option<T>[] | OptionGroup<T>[] | - | The list to choose from, flat or grouped. |
type | 'single' | 'multiple' | 'single' | Decides the shape of value and onchange. |
value | T | T[] | - | The selection. Bindable. |
onchange | (selection) => void | - | Fired when the user picks, toggles or clears. |
open | boolean | false | Popover state. Bindable. |
search | string | '' | The search term. Bindable. |
onsearch | (search: string) => void | - | Fired as the user types. For server-side search. |
searchDebounce | number | 0 | Milliseconds to wait before onsearch fires. |
shouldFilter | boolean | true | Client-side filtering. false when the server filtered. |
filter | (option, search) => boolean | folded match | Replaces the built-in matching. |
clearable | boolean | false | Adds a clear control; re-picking deselects. |
loading | boolean | false | Swaps the list for an indicator. |
disabled | boolean | false | Blocks the trigger. |
name | string | - | Submits with a surrounding form, one field per value. |
form | string | - | id of the form, for a combobox outside it. |
required | boolean | false | Blocks submission while nothing is selected. |
serialize | (value: T) => string | String | Turns a value into the submitted string. Object values need it. |
maxDisplay | number | 3 | Badges before collapsing into a counter. |
placeholder | string | Select an option... | Trigger text while nothing is selected. |
searchPlaceholder | string | Search... | Placeholder for the search box. |
emptyMessage | string | No results found. | Shown when the search matches nothing. |
clearLabel | string | Clear selection | Accessible label for the clear control. |
id | string | generated | Put on the trigger, for a <Label for>. |
aria-* | aria-label, -labelledby, -describedby, -invalid | - | Put on the trigger. |
class | string | - | Merged onto the trigger button. |
contentClass | string | - | Merged onto the popover content. |
listClass | string | - | Merged onto the scrolling list - e.g. its max height. |
Snippets
| Snippet | Receives | Replaces |
|---|---|---|
trigger | { props, selected, open, disabled, clear } | The whole trigger. |
option | { option, selected } | The body of a row. |
empty | - | The empty state. |
indicator | - | The loading row. |
footer | { selected, visible, clear, selectAll, close } | Nothing - it is added below the list. |
Option
| Field | Type | Description |
|---|---|---|
value | T | Matched with ===. |
label | string | Shown, and searched. |
description | string | Second line. Searched. |
keywords | string[] | Searched, never shown. |
disabled | boolean | Blocks selection; stays visible and searchable. |
The defaults are English because Coral is written in English. Every consuming project passes its own copy. These exist so the component renders during a spike, not as a translation layer.
Reacting to a selection
bind:value keeps state in sync. onchange answers a different question: the user just chose
something. It receives the selection, in the same shape as value but hydrated into options: Option<T> | undefined for a single select, Option<T>[] for a multiple one.
It does not also receive the raw value, because that would be the same fact twice: option.value recovers it, and bind:value already has it. The reverse is not free: handed a bare value, a
caller who wants the label has to search the list it just passed in.
Selected: none · onchange fired 0 times
<script lang="ts">
import Combobox from '#lib/components/coral/kit/combobox/combobox.svelte';
import { Button } from '#lib/components/ui/button/index.js';
const fruits = [
{ value: 'acai', label: 'Açaí' },
{ value: 'mango', label: 'Mango' },
{ value: 'kiwi', label: 'Kiwi' }
];
let fruit = $state<string>();
let log = $state<string[]>([]);
</script>
<div class="flex w-72 flex-col gap-3">
<Combobox
options={fruits}
bind:value={fruit}
placeholder="Select a fruit..."
onchange={(option) => (log = [...log, `${option?.label ?? 'cleared'} (${option?.value})`])}
/>
<!-- Assigning to `value` from code does not fire onchange - nobody chose anything. -->
<Button variant="outline" size="sm" onclick={() => (fruit = 'mango')}>Set to Mango in code</Button
>
<p class="text-sm text-muted-foreground">
Selected: {fruit ?? 'none'} · onchange fired {log.length}
{log.length === 1 ? 'time' : 'times'}
</p>
</div>It fires from the selection handler and nowhere else, so it never fires on mount and never fires
when value is assigned from code, as the button in that demo shows.
That distinction is the reason it exists rather than being left to the caller. Deriving the signal from the value instead:
<!-- Don't. Fires on mount, and on every programmatic assignment. -->
$effect(() => save(city));is the usual shortcut, and it ships the same bug every time: an onchange('') on mount, before
anyone has touched the control.
Use bind:value when you only need the state, onchange when something should happen. Both
together is fine.
For type="multiple" the selection is the whole list, not the row that toggled, which is what
makes a bulk change legible too: clear reports [] and the footer's selectAll reports the whole
resulting selection.
A single changed row could only have reported nothing. When the delta is what matters, diff against
the previous value.
Multiple selection
type="multiple" switches value to an array. The trigger collapses into badges plus a counter
past maxDisplay, and the popover stays open while picking. Choosing one of several is rarely
choosing the last one.
["editor"]
onchange saw: nothing yet
<script lang="ts">
import Combobox from '#lib/components/coral/kit/combobox/combobox.svelte';
import { Button } from '#lib/components/ui/button/index.js';
const roles = [
{ value: 'admin', label: 'Administrator' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
{ value: 'auditor', label: 'Auditor' },
{ value: 'billing', label: 'Billing' }
];
let selected = $state<string[]>(['editor']);
let lastChange = $state('nothing yet');
</script>
<div class="flex w-72 flex-col gap-3">
<Combobox
type="multiple"
options={roles}
bind:value={selected}
clearable
placeholder="Select roles..."
searchPlaceholder="Search roles..."
onchange={(picked) => (lastChange = picked.map((option) => option.label).join(', ') || 'none')}
>
{#snippet footer({ selected: picked, visible, clear, selectAll })}
<div class="flex items-center justify-between gap-2 px-1">
<span class="text-xs text-muted-foreground">
{picked.length} of {visible.length}
</span>
<div class="flex gap-1">
<Button variant="ghost" size="xs" onclick={selectAll}>All</Button>
<Button variant="ghost" size="xs" onclick={clear}>None</Button>
</div>
</div>
{/snippet}
</Combobox>
<p class="text-sm text-muted-foreground">{JSON.stringify(selected)}</p>
<!-- `type="multiple"` makes onchange receive every selected option, so `.label` is right here. -->
<p class="text-sm text-muted-foreground">onchange saw: {lastChange}</p>
</div>The footer snippet gets selectAll, clear, the current selection and everything passing the
filter, which is enough to build bulk actions without Coral guessing what they should say.
selectAlladds what is visible to the selection rather than replacing it. With a search term active the two differ, and replacing would deselect the options the filter is hiding (silently, since they are exactly the rows the user cannot see).
Badges in the trigger are not individually removable. A button nested inside a button is invalid HTML, and browsers recover by dropping one of the two, which is how a per-badge remove control ends up unreachable by keyboard. Deselect from the list, or use the clear control.
Groups, descriptions and keywords
<script lang="ts">
import Combobox from '#lib/components/coral/kit/combobox/combobox.svelte';
// Grouped, with descriptions and hidden keywords. `berry` finds Açaí; `4` finds Mango.
const fruits = [
{
label: 'Tropical',
options: [
{ value: 'acai', label: 'Açaí', description: 'Berry', keywords: ['berry', '1'] },
{ value: 'mango', label: 'Mango', description: 'Stone fruit', keywords: ['4'] },
{ value: 'papaya', label: 'Papaya', description: 'Tropical' }
]
},
{
label: 'Citrus',
options: [
{ value: 'orange', label: 'Orange', description: 'Citrus' },
{ value: 'lemon', label: 'Lemon', description: 'Citrus' },
{ value: 'kumquat', label: 'Kumquat', description: 'Citrus', disabled: true }
]
}
];
let fruit = $state<string>();
</script>
<div class="w-72">
<Combobox
options={fruits}
bind:value={fruit}
clearable
placeholder="Select a fruit..."
searchPlaceholder="Try berry, mango, citrus..."
/>
</div>Pass groups instead of options and each gets a heading. description renders as a second line and
is searched. keywords are searched but never shown: synonyms, codes, an old name. Try berry, mango or citrus in that demo.
Filtering removes rows rather than hiding them, so a group whose options all fail the search disappears along with its heading. No empty section is left behind.
Server-side search
onsearch fires as the user types, debounced by searchDebounce. Fetch, hand the results back
through options, and set shouldFilter={false} so a list the server already filtered is not
filtered twice.
Showing 0 · 1 request sent
<script lang="ts">
import Combobox from '#lib/components/coral/kit/combobox/combobox.svelte';
import { fold } from '#lib/components/coral/lib/fold.js';
// Stands in for a paginated endpoint: too many rows to ship to the client.
const CATALOG = Array.from({ length: 400 }, (_, i) => ({
value: i + 1,
label: `repo-${String(i + 1).padStart(3, '0')}`,
description: i % 3 === 0 ? 'N. Virginia' : i % 3 === 1 ? 'São Paulo' : 'Frankfurt'
}));
let options = $state<typeof CATALOG>([]);
let loading = $state(false);
let repoId = $state<number>();
let requests = $state(0);
async function fetchOptions(search: string) {
loading = true;
requests += 1;
await new Promise((r) => setTimeout(r, 220));
// Folding is the server's job once the server owns the search: `sao paulo` still has to
// find `São Paulo` there. Same `fold` the component uses when it filters on the client.
const needle = fold(search.trim());
options = CATALOG.filter(
(o) => !needle || fold(o.label).includes(needle) || fold(o.description).includes(needle)
).slice(0, 20);
loading = false;
}
fetchOptions('');
</script>
<div class="flex w-72 flex-col gap-3">
<!-- The server already filtered, so the client must not filter again. -->
<Combobox
{options}
{loading}
bind:value={repoId}
shouldFilter={false}
onsearch={fetchOptions}
searchDebounce={300}
placeholder="Select a repository..."
searchPlaceholder="Search 400 repositories..."
emptyMessage="No repository matches."
/>
<p class="text-sm text-muted-foreground">
Showing {options.length} · {requests} request{requests === 1 ? '' : 's'} sent
</p>
</div>loading swaps the list for an indicator. The search term is cleared when the popover closes, so
the next open starts from the full list rather than from whatever was typed last time.
Custom trigger and options
<script lang="ts">
import Combobox from '#lib/components/coral/kit/combobox/combobox.svelte';
import { Button } from '#lib/components/ui/button/index.js';
import CircleDashedIcon from '@lucide/svelte/icons/circle-dashed';
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import CircleCheckIcon from '@lucide/svelte/icons/circle-check';
const statuses = [
{ value: 'todo', label: 'Todo' },
{ value: 'doing', label: 'In progress' },
{ value: 'done', label: 'Done' }
];
const ICONS = { todo: CircleDashedIcon, doing: CircleDotIcon, done: CircleCheckIcon };
let status = $state<string>('todo');
</script>
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground">Status</span>
<Combobox options={statuses} bind:value={status} searchPlaceholder="Change status...">
{#snippet trigger({ props, selected })}
{@const Icon = selected[0]
? ICONS[selected[0].value as keyof typeof ICONS]
: CircleDashedIcon}
<Button {...props} variant="outline" size="sm">
<Icon />
{selected[0]?.label ?? 'Set status'}
</Button>
{/snippet}
{#snippet option({ option })}
{@const Icon = ICONS[option.value as keyof typeof ICONS]}
<Icon />
{option.label}
{/snippet}
</Combobox>
</div>The trigger snippet replaces the button entirely. Spread props onto whatever you render and
the popover still wires itself up. The option snippet replaces the body of each row, and the
theme's check indicator stays.
Between those two, footer, empty and indicator, every visible part is replaceable without
dropping Coral and rebuilding from raw shadcn.
Typed values
Option<T> is generic. The value stays whatever the project already has (an id, an enum member)
and comes back out the same type, with no conversion at the call site.
Selected id: none (undefined)
<script lang="ts">
import Combobox from '#lib/components/coral/kit/combobox/combobox.svelte';
// `value` is whatever the project already has - here a numeric id, not a string.
const projects = [
{ value: 1, label: 'web-dashboard' },
{ value: 2, label: 'billing-api' },
{ value: 3, label: 'legacy-gateway', disabled: true },
{ value: 4, label: 'docs-site' }
];
let projectId = $state<number>();
</script>
<div class="flex w-64 flex-col gap-3">
<Combobox
options={projects}
bind:value={projectId}
placeholder="Select a project..."
searchPlaceholder="Search projects..."
emptyMessage="No project found."
/>
<p class="text-sm text-muted-foreground">
Selected id: {projectId ?? 'none'} ({typeof projectId})
</p>
</div>Options are matched with ===, so object values compare by reference. disabled on an option is
forwarded to the command item.
Forms
name renders one field per selected value, so type="multiple" posts the shape FormData.getAll(name) already reads back as a list. form points those fields at a form by id,
for a combobox that renders outside it. required blocks submission while nothing is selected.
The search term is forgotten whenever the popover closes - by Escape, by a pick, and by the close() handed to a footer, which assigns open and is the close the primitive never reports. A
term left behind would filter the list before anyone had typed anything the next time.
Values are stringified with String by default, which is right for ids, numbers and enum members.
Object values need serialize, or they submit as [object Object]:
<Combobox {options} bind:value name="project" serialize={(v) => String(v.id)} />Accessibility
The trigger carries role="combobox" and aria-expanded; the list is a listbox; disabled
options carry aria-disabled and are skipped by both pointer and keyboard. Selection returns focus
to the trigger, and in type="multiple" to the search box, so the keyboard never lands back at the
top of the document.
Three things the primitives leave out, which Coral supplies from outside them:
- The active option is announced. The search box carries
aria-controlsandaria-activedescendant, so a screen reader is told which list it drives and which option the arrow keys are on. The primitive derives both from an element the shadcn list never renders. - An empty list is not left as an empty listbox. A listbox must own options, so while nothing matches, or the results are loading, the list is hidden and the message or the indicator sits beside it. With the default message, the result is also spoken: typing into a box whose list has emptied otherwise says nothing at all.
- A disabled option looks disabled. The shadcn item styles
data-[disabled=true], while the primitive rendersdata-disabled="", so the dimming never applied.
Why flat props, for now
The conventions call for composition when the parts vary independently, and a combobox eventually does. This version stays flat and hands the varying parts to snippets instead, because the canonical case (a list, a value, a search box) is the one every caller writes first.
Two decisions keep that from becoming a dead end:
- The value type is generic from day one. Widening a closed
stringlater would break every project that had already worked around it. - The popover root's props are forwarded, so
openandonOpenChangeare already the caller's.
What is not here yet is deferred, not designed away. Splitting the trigger, the list and the search box into separate parts is the extension that stays open, and it can land without moving any of the props above.
lib/fold
The search folding lives in lib/fold.ts and is exported on its own, so the same comparison can be
used anywhere the same question is asked:
import { fold } from '#lib/components/coral/lib/fold.js';
fold('Açaí'); // 'acai'
fold('Piña'); // 'pina'
fold('Café'); // 'cafe'It lives in lib/fold.ts rather than beside the combobox because data table searches the same way, and the two cannot be allowed to drift.