Combobox
Autocomplete on Command-inside-Popover. The panel springs open on scale and opacity; option focus moves in a crisp highlight step rather than a fade — keep the keyboard the primary affordance.
Install
import { Combobox } from '$lib/components/ui/combobox'; Usage
<script lang="ts">
import { Combobox } from '$lib/components/ui/combobox';
const frameworks = [
{ value: 'svelte', label: 'Svelte' },
{ value: 'solid', label: 'Solid' },
{ value: 'react', label: 'React' }
];
let value = $state('');
</script>
<Combobox options={frameworks} bind:value placeholder="Select framework…" />States
<script lang="ts" module>
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from '$lib/utils.js';
export type ComboboxOption = {
value: string;
label: string;
disabled?: boolean;
keywords?: string[];
};
export type ComboboxProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
options: ComboboxOption[];
value?: string;
open?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyLabel?: string;
disabled?: boolean;
triggerClass?: string;
contentClass?: string;
};
</script>
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import * as Command from '$lib/components/ui/command';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils.js';
import { Icon } from '$lib/components/ui/icon';
let {
options,
value = $bindable(''),
open = $bindable(false),
placeholder = 'Select option…',
searchPlaceholder = 'Search…',
emptyLabel = 'No results.',
disabled = false,
class: className,
triggerClass,
contentClass,
ref = $bindable(null)
}: ComboboxProps = $props();
const selectedLabel = $derived(options.find((o) => o.value === value)?.label ?? placeholder);
function onSelect(next: string) {
value = next === value ? '' : next;
open = false;
}
</script>
<div bind:this={ref} data-slot="combobox" class={cn('relative', className)}>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
{disabled}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
class={cn('w-56 justify-between bg-input/30 font-normal', triggerClass)}
>
<span class={cn(!value && 'text-muted-foreground')}>{selectedLabel}</span>
<Icon
name="expand-up-down-line"
class="ml-2 size-4 shrink-0 opacity-50"
size="1rem"
aria-hidden="true"
/>
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class={cn('w-56 p-0', contentClass)}>
<Command.Root>
<Command.Input placeholder={searchPlaceholder} />
<Command.List>
<Command.Empty>{emptyLabel}</Command.Empty>
<Command.Group>
{#each options as option (option.value)}
<Command.Item
value={option.value}
keywords={option.keywords ?? [option.label]}
disabled={option.disabled}
data-checked={value === option.value}
shape="pill"
onSelect={() => onSelect(option.value)}
>
{option.label}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
</div>
Popover composition
When the prebuilt Combobox isn't flexible enough, compose Popover + Command directly. This gives
full control over grouping, footers, custom items, and trigger shape.
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import * as Command from '$lib/components/ui/command';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils.js';
import { Icon } from '$lib/components/ui/icon';
const options = [
{ value: 'svelte', label: 'Svelte' },
{ value: 'solid', label: 'Solid' },
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'qwik', label: 'Qwik' }
];
let value = $state('');
let open = $state(false);
const label = $derived(options.find((o) => o.value === value)?.label ?? 'Select framework…');
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
role="combobox"
aria-expanded={open}
class="w-56 justify-between font-normal"
>
<span class={cn(!value && 'text-muted-foreground')}>{label}</span>
<Icon name="expand-up-down-line" class="ml-2 size-4 shrink-0 opacity-50" size="1rem" aria-hidden="true" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-56 p-0">
<Command.Root>
<Command.Input placeholder="Search…" />
<Command.List>
<Command.Empty>No results.</Command.Empty>
<Command.Group>
{#each options as opt (opt.value)}
<Command.Item
value={opt.value}
keywords={[opt.label]}
onSelect={() => {
value = value === opt.value ? '' : opt.value;
open = false;
}}
>
<Icon
name="check-line"
class={cn('mr-2 size-4', value === opt.value ? 'opacity-100' : 'opacity-0')}
/>
{opt.label}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>Dropdown composition
DropdownMenu + Command gives searchable
items inside a menu surface — useful when the combobox lives inside a toolbar or action bar where
a popover anchor is awkward.
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Command from '$lib/components/ui/command';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils.js';
import { Icon } from '$lib/components/ui/icon';
const options = [
{ value: 'svelte', label: 'Svelte' },
{ value: 'solid', label: 'Solid' },
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'qwik', label: 'Qwik' }
];
let value = $state('');
let open = $state(false);
const label = $derived(options.find((o) => o.value === value)?.label ?? 'Select framework…');
</script>
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
role="combobox"
aria-expanded={open}
class="w-56 justify-between font-normal"
>
<span class={cn(!value && 'text-muted-foreground')}>{label}</span>
<Icon name="expand-up-down-line" class="ml-2 size-4 shrink-0 opacity-50" size="1rem" aria-hidden="true" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-56 p-0">
<Command.Root>
<Command.Input placeholder="Search…" />
<Command.List>
<Command.Empty>No results.</Command.Empty>
<Command.Group>
{#each options as opt (opt.value)}
<Command.Item
value={opt.value}
keywords={[opt.label]}
onSelect={() => {
value = value === opt.value ? '' : opt.value;
open = false;
}}
>
<Icon
name="check-line"
class={cn('mr-2 size-4', value === opt.value ? 'opacity-100' : 'opacity-0')}
/>
{opt.label}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</DropdownMenu.Content>
</DropdownMenu.Root>Responsive
On mobile a bottom Drawer replaces the floating popover — a more
thumb-reachable surface on narrow viewports. A matchMedia rune switches
the surface at the md breakpoint.
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import * as Drawer from '$lib/components/ui/drawer';
import * as Command from '$lib/components/ui/command';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils.js';
import { Icon } from '$lib/components/ui/icon';
const options = [
{ value: 'svelte', label: 'Svelte' },
{ value: 'solid', label: 'Solid' },
{ value: 'react', label: 'React' },
{ value: 'vue', label: 'Vue' },
{ value: 'qwik', label: 'Qwik' }
];
let value = $state('');
let open = $state(false);
let isDesktop = $state(true);
$effect(() => {
const mq = window.matchMedia('(min-width: 768px)');
isDesktop = mq.matches;
const handler = (e: MediaQueryListEvent) => { isDesktop = e.matches; };
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
const label = $derived(options.find((o) => o.value === value)?.label ?? 'Select framework…');
function onSelect(v: string) {
value = value === v ? '' : v;
open = false;
}
</script>
{#snippet triggerButton(props: Record<string, unknown>)}
<Button
{...props}
variant="outline"
role="combobox"
aria-expanded={open}
class="w-56 justify-between font-normal"
>
<span class={cn(!value && 'text-muted-foreground')}>{label}</span>
<Icon name="expand-up-down-line" class="ml-2 size-4 shrink-0 opacity-50" size="1rem" aria-hidden="true" />
</Button>
{/snippet}
{#snippet optionList()}
<Command.Root>
<Command.Input placeholder="Search…" />
<Command.List>
<Command.Empty>No results.</Command.Empty>
<Command.Group>
{#each options as opt (opt.value)}
<Command.Item value={opt.value} keywords={[opt.label]} onSelect={() => onSelect(opt.value)}>
<Icon name="check-line" class={cn('mr-2 size-4', value === opt.value ? 'opacity-100' : 'opacity-0')} />
{opt.label}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
{/snippet}
{#if isDesktop}
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
{@render triggerButton(props)}
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-56 p-0">
{@render optionList()}
</Popover.Content>
</Popover.Root>
{:else}
<Drawer.Root bind:open>
<Drawer.Trigger>
{#snippet child({ props })}
{@render triggerButton(props)}
{/snippet}
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay />
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Select framework</Drawer.Title>
</Drawer.Header>
<div class="px-4 pb-4">
{@render optionList()}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
{/if}API reference
Wraps Popover + Command under the hood. The options prop is the fast path; drop to direct Command composition when you need grouped or footer-rich menus.
| Prop | Type | Default | Description |
|---|---|---|---|
ComboboxOption[] | — | Array of { value, label, disabled?, keywords? }. Keywords feed the Command search filter. | |
string (bindable) | '' | Selected option value. Selecting the active option again clears it. | |
boolean (bindable) | false | Two-way bindable popover open state. | |
string | 'Select option…' | Trigger label shown when value is empty. | |
string | 'Search…' | Placeholder inside the Command input. | |
string | 'No results.' | Rendered inside Command.Empty when filtering returns nothing. | |
boolean | false | Disables the trigger button. | |
string | — | Additional classes merged onto the trigger Button. | |
string | — | Additional classes merged onto the Popover.Content panel. |