Combobox
Selection & Inputimport { Combobox } from "bits-ui" Filtered list
The input owns the query, an each/else block supplies the empty state, and the selected value remains separately bound
No deployment region selected
<script lang="ts">
import { Combobox } from 'bits-ui';
import { Check, ChevronDown, Search } from '@lucide/svelte';
const items = [
{ value: 'amsterdam', label: 'Amsterdam', region: 'Europe' },
{ value: 'berlin', label: 'Berlin', region: 'Europe' },
{ value: 'copenhagen', label: 'Copenhagen', region: 'Europe' },
{ value: 'montreal', label: 'Montréal', region: 'North America' },
{ value: 'seattle', label: 'Seattle', region: 'North America' },
{ value: 'singapore', label: 'Singapore', region: 'Asia Pacific' }
];
let value = $state('');
let query = $state('');
const filteredItems = $derived(
query.trim() === ''
? items
: items.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase()))
);
const selectedLabel = $derived(items.find((item) => item.value === value)?.label);
</script>
<div class="grid w-full max-w-sm gap-3">
<label class="text-sm font-medium" for="office-search">Deployment region</label>
<Combobox.Root
type="single"
bind:value
items={items.map(({ value: itemValue, label }) => ({ value: itemValue, label }))}
name="deploymentRegion"
onOpenChangeComplete={(open) => {
if (!open) query = '';
}}
>
<div class="relative">
<Search class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-400" />
<Combobox.Input
id="office-search"
class="input w-full pr-10 pl-9"
placeholder="Search regions…"
aria-label="Search deployment regions"
oninput={(event) => (query = event.currentTarget.value)}
/>
<Combobox.Trigger
class="absolute top-1/2 right-2 grid size-8 -translate-y-1/2 place-items-center rounded-md text-ink-400 hover:bg-ink-800 hover:text-ink-50"
aria-label="Toggle deployment regions"
>
<ChevronDown class="size-4" />
</Combobox.Trigger>
</div>
<Combobox.Portal>
<Combobox.Content
class="panel z-50 min-w-64 w-[var(--bits-combobox-anchor-width)] animate-pop-in p-1"
sideOffset={8}
>
<Combobox.Viewport class="max-h-64 overflow-y-auto p-1">
{#each filteredItems as item (item.value)}
<Combobox.Item
value={item.value}
label={item.label}
class="flex cursor-default items-center gap-3 rounded-lg px-2.5 py-2 text-sm outline-none data-highlighted:bg-ink-800 data-highlighted:text-ink-50 data-selected:text-brand-400"
>
{#snippet children({ selected })}
<span class="min-w-0 flex-1">
<span class="block">{item.label}</span>
<span class="block text-xs text-ink-400">{item.region}</span>
</span>
{#if selected}<Check class="size-4 shrink-0" />{/if}
{/snippet}
</Combobox.Item>
{:else}
<div class="px-3 py-6 text-center text-sm text-ink-400">
No region matches “{query}”.
</div>
{/each}
</Combobox.Viewport>
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
<p class="muted text-xs">
{selectedLabel ? `Selected: ${selectedLabel}` : 'No deployment region selected'}
</p>
</div>
Multiple with chips
A string-array selection is projected into removable chips without conflating selected values with the search text
<script lang="ts">
import { Combobox } from 'bits-ui';
import { Check, ChevronDown, Search, X } from '@lucide/svelte';
const skills = [
{ value: 'accessibility', label: 'Accessibility' },
{ value: 'design-systems', label: 'Design systems' },
{ value: 'performance', label: 'Web performance' },
{ value: 'security', label: 'Application security' },
{ value: 'svelte', label: 'Svelte' },
{ value: 'typescript', label: 'TypeScript' }
];
let value = $state<string[]>(['accessibility', 'svelte']);
let query = $state('');
const filteredSkills = $derived(
query.trim() === ''
? skills
: skills.filter((skill) => skill.label.toLowerCase().includes(query.trim().toLowerCase()))
);
const selectedSkills = $derived(skills.filter((skill) => value.includes(skill.value)));
function removeSkill(skillValue: string) {
value = value.filter((current) => current !== skillValue);
}
</script>
<div class="grid w-full max-w-md gap-3">
<div class="flex items-baseline justify-between gap-3">
<span class="text-sm font-medium">Reviewer expertise</span>
<span class="muted text-xs">{value.length} selected</span>
</div>
<div class="flex min-h-8 flex-wrap gap-2">
{#each selectedSkills as skill (skill.value)}
<span class="chip gap-1 pr-1">
{skill.label}
<button
type="button"
class="rounded p-0.5 text-ink-400 hover:bg-ink-700 hover:text-ink-50"
onclick={() => removeSkill(skill.value)}
aria-label={`Remove ${skill.label}`}
>
<X class="size-3" />
</button>
</span>
{/each}
</div>
<Combobox.Root
type="multiple"
bind:value
name="reviewerExpertise"
onOpenChangeComplete={(open) => {
if (!open) query = '';
}}
>
<div class="relative">
<Search class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-400" />
<Combobox.Input
class="input w-full pr-10 pl-9"
placeholder="Add expertise…"
aria-label="Search reviewer expertise"
oninput={(event) => (query = event.currentTarget.value)}
/>
<Combobox.Trigger
class="absolute top-1/2 right-2 grid size-8 -translate-y-1/2 place-items-center rounded-md text-ink-400 hover:bg-ink-800 hover:text-ink-50"
aria-label="Toggle expertise options"
>
<ChevronDown class="size-4" />
</Combobox.Trigger>
</div>
<Combobox.Portal>
<Combobox.Content
class="panel z-50 min-w-64 w-[var(--bits-combobox-anchor-width)] animate-pop-in p-1"
sideOffset={8}
>
<Combobox.Viewport class="max-h-64 overflow-y-auto p-1">
{#each filteredSkills as skill (skill.value)}
<Combobox.Item
value={skill.value}
label={skill.label}
class="flex cursor-default items-center gap-3 rounded-lg px-2.5 py-2 text-sm outline-none data-highlighted:bg-ink-800 data-highlighted:text-ink-50 data-selected:text-brand-400"
>
{#snippet children({ selected })}
<span class="flex-1">{skill.label}</span>
<span class="grid size-5 place-items-center rounded border border-ink-700">
{#if selected}<Check class="size-3.5" />{/if}
</span>
{/snippet}
</Combobox.Item>
{:else}
<div class="px-3 py-6 text-center text-sm text-ink-400">No expertise found.</div>
{/each}
</Combobox.Viewport>
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
</div>
type is required: single mode binds a string and multiple mode binds a string array. Filtering is intentionally application-owned; update a query from Combobox.Input and render only matching items. The input retains DOM focus while keyboard navigation marks items with data-highlighted.
Providing name creates the hidden form input. For single mode, an items value/label array also enables browser autofill; use inputValue when an externally changed selection must force new display text. Portalled content exposes anchor-size and available-space CSS variables for sizing.