Combobox

Autocomplete input with a list of suggestions.

Installation

Install the dependencies

pnpm add cn github:sveltery/base

Copy the component

Copy the following files into src/lib/components/ui/combobox.

It imports the button and input-group components, so copy their folders too.

src/lib/components/ui/combobox/ComboboxChip.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  import { Button } from '../button/index.js';
  import XIcon from '@lucide/svelte/icons/x';
  let { children, class: classProp, showRemove = true, ...props }: ComponentProps<typeof Primitive.Chip> & { showRemove?: boolean } = $props();
</script>

<Primitive.Chip data-slot="combobox-chip" {...props} class={cn('bg-muted text-foreground flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-data-[slot=combobox-chip-remove]:pr-0 has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50', classProp)}>
  {@render children?.()}
  {#if showRemove}
    <Primitive.ChipRemove data-slot="combobox-chip-remove" class="-ml-1 opacity-50 hover:opacity-100">
      {#snippet render(hostProps, _state, content)}
        <Button variant="ghost" size="icon-xs" {...hostProps}>
          {@render content?.()}
        </Button>
      {/snippet}
      <XIcon class="pointer-events-none" />
    </Primitive.ChipRemove>
  {/if}
</Primitive.Chip>
src/lib/components/ui/combobox/ComboboxChips.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Chips> = $props();
</script>

<Primitive.Chips data-slot="combobox-chips" {children} {...props} class={cn('dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-8 flex-wrap items-center gap-1 rounded-lg border bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:ring-3 has-aria-invalid:ring-3 has-data-[slot=combobox-chip]:px-1', classProp)} />
src/lib/components/ui/combobox/ComboboxChipsInput.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  let { class: classProp, ...props }: ComponentProps<typeof Primitive.Input> = $props();
</script>

<Primitive.Input data-slot="combobox-chip-input" {...props} class={cn('min-w-16 flex-1 outline-none', classProp)} />
src/lib/components/ui/combobox/ComboboxClear.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  import XIcon from '@lucide/svelte/icons/x';
  import { InputGroupButton } from '../input-group/index.js';
  let { class: classProp, ...props }: ComponentProps<typeof Primitive.Clear> = $props();
</script>

<Primitive.Clear data-slot="combobox-clear" {...props} class={cn('', classProp)}>
  {#snippet render(hostProps, _state, content)}
    <InputGroupButton variant="ghost" size="icon-xs" {...hostProps}>
      {@render content?.()}
    </InputGroupButton>
  {/snippet}
  <XIcon class="pointer-events-none" />
</Primitive.Clear>
src/lib/components/ui/combobox/ComboboxContent.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  let {
    children,
    class: classProp,
    side = 'bottom',
    sideOffset = 6,
    align = 'start',
    alignOffset = 0,
    anchor,
    ...props
  }: ComponentProps<typeof Primitive.Popup> & Pick<ComponentProps<typeof Primitive.Positioner>, 'align' | 'alignOffset' | 'anchor' | 'side' | 'sideOffset'> = $props();
</script>

<Primitive.Portal>
  <Primitive.Positioner {side} {sideOffset} {align} {alignOffset} {anchor} class="isolate z-50">
    <Primitive.Popup
      data-slot="combobox-content"
      data-chips={!!anchor}
      {...props}
      class={cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 overflow-hidden rounded-lg shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)', classProp)}
    >
      {@render children?.()}
    </Primitive.Popup>
  </Primitive.Positioner>
</Primitive.Portal>
src/lib/components/ui/combobox/ComboboxEmpty.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Empty> = $props();
</script>

<Primitive.Empty data-slot="combobox-empty" {children} {...props} class={cn('text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex', classProp)} />
src/lib/components/ui/combobox/ComboboxGroup.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Group> = $props();
</script>

<Primitive.Group data-slot="combobox-group" {children} {...props} class={cn('', classProp)} />
src/lib/components/ui/combobox/ComboboxInput.svelte
<script lang="ts">
  import type { ComponentProps, Snippet } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../input-group/index.js';
  import ComboboxClear from './ComboboxClear.svelte';
  import ComboboxTrigger from './ComboboxTrigger.svelte';
  let {
    children,
    class: classProp,
    disabled = false,
    showTrigger = true,
    showClear = false,
    ...props
  }: Omit<ComponentProps<typeof Primitive.Input>, 'children'> & { children?: Snippet; showTrigger?: boolean; showClear?: boolean } = $props();
</script>

<InputGroup class={cn('w-auto', classProp)}>
  <Primitive.Input {disabled} {...props}>
    {#snippet render(hostProps)}
      <InputGroupInput {...hostProps} />
    {/snippet}
  </Primitive.Input>
  <InputGroupAddon align="inline-end">
    {#if showTrigger}
      <ComboboxTrigger {disabled} data-slot="input-group-button" class="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent">
        {#snippet render(hostProps, _state, content)}
          <InputGroupButton size="icon-xs" variant="ghost" {...hostProps}>
            {@render content?.()}
          </InputGroupButton>
        {/snippet}
      </ComboboxTrigger>
    {/if}
    {#if showClear}
      <ComboboxClear {disabled} />
    {/if}
  </InputGroupAddon>
  {@render children?.()}
</InputGroup>
src/lib/components/ui/combobox/ComboboxItem.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  import CheckIcon from '@lucide/svelte/icons/check';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Item> = $props();
</script>

<Primitive.Item
  data-slot="combobox-item"
  {...props}
  class={cn("data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", classProp)}
>
  {@render children?.()}
  <Primitive.ItemIndicator class="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
    <CheckIcon class="pointer-events-none" />
  </Primitive.ItemIndicator>
</Primitive.Item>
src/lib/components/ui/combobox/ComboboxLabel.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.GroupLabel> = $props();
</script>

<Primitive.GroupLabel data-slot="combobox-label" {children} {...props} class={cn('text-muted-foreground px-2 py-1.5 text-xs', classProp)} />
src/lib/components/ui/combobox/ComboboxList.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.List> = $props();
</script>

<Primitive.List data-slot="combobox-list" {children} {...props} class={cn('no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 p-1 data-empty:p-0 overflow-y-auto overscroll-contain', classProp)} />
src/lib/components/ui/combobox/ComboboxSeparator.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  let { class: classProp, ...props }: ComponentProps<typeof Primitive.Separator> = $props();
</script>

<Primitive.Separator data-slot="combobox-separator" {...props} class={cn('bg-border -mx-1 my-1 h-px', classProp)} />
src/lib/components/ui/combobox/ComboboxTrigger.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  import { cn } from 'cn';
  import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
  let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Trigger> = $props();
</script>

<Primitive.Trigger data-slot="combobox-trigger" {...props} class={cn("[&_svg:not([class*='size-'])]:size-4", classProp)}>
  {@render children?.()}
  <ChevronDownIcon class="text-muted-foreground size-4 pointer-events-none" />
</Primitive.Trigger>
src/lib/components/ui/combobox/ComboboxValue.svelte
<script lang="ts">
  import type { ComponentProps } from 'svelte';
  import { Combobox as Primitive } from '@sveltery/base/combobox';
  let { children, ...props }: ComponentProps<typeof Primitive.Value> = $props();
</script>

<Primitive.Value {children} {...props} />
src/lib/components/ui/combobox/index.ts
import { Combobox as Primitive } from '@sveltery/base/combobox';

export const Combobox = Primitive.Root as typeof Primitive.Root;
export const ComboboxCollection = Primitive.Collection;
export { default as ComboboxInput } from './ComboboxInput.svelte';
export { default as ComboboxContent } from './ComboboxContent.svelte';
export { default as ComboboxList } from './ComboboxList.svelte';
export { default as ComboboxItem } from './ComboboxItem.svelte';
export { default as ComboboxGroup } from './ComboboxGroup.svelte';
export { default as ComboboxLabel } from './ComboboxLabel.svelte';
export { default as ComboboxEmpty } from './ComboboxEmpty.svelte';
export { default as ComboboxSeparator } from './ComboboxSeparator.svelte';
export { default as ComboboxChips } from './ComboboxChips.svelte';
export { default as ComboboxChip } from './ComboboxChip.svelte';
export { default as ComboboxChipsInput } from './ComboboxChipsInput.svelte';
export { default as ComboboxTrigger } from './ComboboxTrigger.svelte';
export { default as ComboboxValue } from './ComboboxValue.svelte';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { Combobox, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from '$lib/components/ui/combobox';

  const frameworks = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro'];
</script>
<Combobox items={frameworks}>
  <ComboboxInput placeholder="Select a framework" />
  <ComboboxContent>
    <ComboboxEmpty>No items found.</ComboboxEmpty>
    <ComboboxList>
      <ComboboxCollection>
        {#snippet children(item)}
          <ComboboxItem value={item}>{item}</ComboboxItem>
        {/snippet}
      </ComboboxCollection>
    </ComboboxList>
  </ComboboxContent>
</Combobox>

Pass items to Combobox so it can filter the list as the user types. ComboboxCollection renders its snippet once for each item that matches, so render the items there instead of with {#each}.

Composition

Use the following compositions to build a Combobox.

Simple

A single-line input and a flat list (see Basic).

Combobox
├── ComboboxInput
└── ComboboxContent
    ├── ComboboxEmpty
    └── ComboboxList
        └── ComboboxCollection
            ├── ComboboxItem
            └── ComboboxItem

With chips

Multi-select with multiple, chips, and a chips input (see Multiple).

Combobox
├── ComboboxChips
│   └── ComboboxValue
│       ├── ComboboxChip
│       └── ComboboxChipsInput
└── ComboboxContent
    ├── ComboboxEmpty
    └── ComboboxList
        └── ComboboxCollection
            ├── ComboboxItem
            └── ComboboxItem

With groups and collection

Nested items per group using ComboboxCollection inside each ComboboxGroup, with a separator between groups (see Groups).

Combobox
├── ComboboxInput
└── ComboboxContent
    ├── ComboboxEmpty
    └── ComboboxList
        └── ComboboxCollection
            ├── ComboboxGroup
            │   ├── ComboboxLabel
            │   └── ComboboxCollection
            │       ├── ComboboxItem
            │       └── ComboboxItem
            ├── ComboboxSeparator
            └── ComboboxGroup
                ├── ComboboxLabel
                └── ComboboxCollection
                    ├── ComboboxItem
                    └── ComboboxItem

Binding the value

Bind value to read or change the selection from outside, inputValue to read the text in the input, and open to control the popup. Pass defaultValue to set the starting value.

<script lang="ts">
  let framework = $state<string | null>('SvelteKit');
  let query = $state('');
</script>

<Combobox items={frameworks} bind:value={framework} bind:inputValue={query}>
  ...
</Combobox>

Custom Items

Use itemToStringValue when your items are objects. It gives the form value and the hidden input a string. itemToStringLabel does the same for the label shown in the input, and an object with a label field needs neither.

<script lang="ts">
  type Framework = { label: string; value: string };

  const frameworks: Framework[] = [
    { label: 'Next.js', value: 'next' },
    { label: 'SvelteKit', value: 'sveltekit' },
    { label: 'Nuxt', value: 'nuxt' }
  ];
</script>

<Combobox items={frameworks} itemToStringValue={(framework: Framework) => framework.label}>
  <ComboboxInput placeholder="Select a framework" />
  <ComboboxContent>
    <ComboboxEmpty>No items found.</ComboboxEmpty>
    <ComboboxList>
      <ComboboxCollection>
        {#snippet children(framework: Framework)}
          <ComboboxItem value={framework}>{framework.label}</ComboboxItem>
        {/snippet}
      </ComboboxCollection>
    </ComboboxList>
  </ComboboxContent>
</Combobox>

Multiple Selection

Use multiple with chips for multi-select behavior. ComboboxValue passes the selected values to its snippet. Attach to ComboboxChips to get its element, and pass it to ComboboxContent as anchor so the popup lines up with the chips instead of the input.

<script lang="ts">
  let anchor = $state<HTMLElement | null>(null);
  let value = $state<string[]>([]);
</script>

<Combobox items={frameworks} multiple bind:value>
  <ComboboxChips {@attach (node) => { anchor = node; }}>
    <ComboboxValue>
      {#snippet children(values)}
        {@const selected = values as string[]}
        {#each selected as item (item)}
          <ComboboxChip>{item}</ComboboxChip>
        {/each}
        <ComboboxChipsInput placeholder="Add framework" />
      {/snippet}
    </ComboboxValue>
  </ComboboxChips>
  <ComboboxContent {anchor}>
    <ComboboxEmpty>No items found.</ComboboxEmpty>
    <ComboboxList>
      <ComboboxCollection>
        {#snippet children(item)}
          <ComboboxItem value={item}>{item}</ComboboxItem>
        {/snippet}
      </ComboboxCollection>
    </ComboboxList>
  </ComboboxContent>
</Combobox>

Forms

Give Combobox a name and it submits the selected value with its form through a hidden input. Set required to block submission until the user picks a value.

<form method="POST">
  <Combobox name="framework" items={frameworks} required>
    ...
  </Combobox>
  <button type="submit">Submit</button>
</form>

Basic

A simple combobox with a list of frameworks.

Multiple

A combobox with multiple selection using multiple and ComboboxChips.

Clear Button

Use the showClear prop to show a clear button.

Groups

Use ComboboxGroup and ComboboxSeparator to group items.

Custom Items

You can render a custom component inside ComboboxItem.

Invalid

Use the aria-invalid prop to make the combobox invalid.

Disabled

Use the disabled prop to disable the combobox.

Auto Highlight

Use the autoHighlight prop to automatically highlight the first item on filter.

You can trigger the combobox from a button or any other component by using the render snippet of ComboboxTrigger. Move the ComboboxInput inside the ComboboxContent.

Input Group

You can add an addon to the combobox by using the InputGroupAddon component inside the ComboboxInput.

RTL

Wrap the combobox in a DirectionProvider and set dir on ComboboxContent. Use the buttons to switch the language.

Examples

Basic
Disabled
Sides
Invalid

Please select a valid framework.

With Clear Button
With Auto Highlight
With Groups
With Groups and Separator
Large List (100 items)
With Icon Addon
Combobox in Popup
Form with Combobox
Combobox Multiple
Combobox Multiple Disabled
Combobox Multiple Invalid

Please select at least one framework.

Combobox Multiple (No Remove)
With Custom Item Rendering
Combobox in Dialog
With Other Inputs
Disabled Items

API reference

Combobox is styled on top of the Sveltery Base Combobox primitive, which owns the filtering, popup positioning, keyboard navigation, focus handling, dismissal and form value. ComboboxContent renders the Base portal, positioner and popup, and takes the positioner's side, align, sideOffset, alignOffset and anchor props. ComboboxInput takes showTrigger and showClear, and ComboboxChip takes showRemove. The trigger and the input group button set data-popup-open and data-pressed while the popup is open, so style the open state with data-popup-open:. See the Base UI Combobox documentation for the behavior it ports.