ShardsUI is in beta. APIs may change before 1.0.

Combobox

An input with a filterable list.

<script lang="ts">
  import { Combobox } from '@shardsui/svelte/combobox'

  type Tag = {
    label: string
    value: string
  }

  const id = $props.id()

  const tags: Tag[] = [
    { label: 'Animation', value: 'animation' },
    { label: 'Design Systems', value: 'design-systems' },
    { label: 'Layouts', value: 'layouts' },
    { label: 'Typography', value: 'typography' },
    { label: 'Color Theory', value: 'color-theory' },
    { label: 'Accessibility', value: 'accessibility' }
  ]
</script>

<Combobox.Root items={tags}>
  <div class="relative flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
    <label for={id}>Select tag</label>
    <Combobox.InputGroup
      class="relative box-content h-8 w-56 rounded-md border border-gray-200 bg-gray-50 focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-gray-950 [&>input]:pr-8 has-[.combobox-clear]:[&>input]:pr-[calc(0.5rem+1.5rem*2)]"
    >
      <Combobox.Input
        {id}
        placeholder="e.g. Animation"
        class="size-full border-0 bg-transparent pl-2 text-sm font-normal text-gray-900 outline-hidden any-pointer-coarse:text-base"
      />
      <div class="absolute right-1 bottom-0 flex h-8 items-center justify-center text-gray-600">
        <Combobox.Clear
          class="combobox-clear flex h-8 w-6 items-center justify-center rounded bg-transparent p-0"
          aria-label="Clear selection"
        >
          {@render crossIcon()}
        </Combobox.Clear>
        <Combobox.Trigger
          class="flex h-8 w-6 items-center justify-center rounded bg-transparent p-0"
          aria-label="Open popup"
        >
          {@render chevronDownIcon()}
        </Combobox.Trigger>
      </div>
    </Combobox.InputGroup>
  </div>

  <Combobox.Portal>
    <Combobox.Positioner class="outline-hidden" sideOffset={4}>
      <Combobox.Popup
        class="max-h-92 w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) rounded-md bg-gray-50 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
      >
        <Combobox.Empty>
          <div class="py-4 pr-4 pl-2 text-sm/4 text-gray-600">No tags found.</div>
        </Combobox.Empty>
        <Combobox.List
          class="max-h-[min(22.5rem,var(--available-height))] scroll-py-1 overflow-y-auto overscroll-contain py-1 outline-0 data-empty:p-0"
        >
          <Combobox.Collection>
            {#snippet children(item)}
              {@const tag = item as Tag}
              <Combobox.Item
                value={tag}
                class="grid grid-cols-[1rem_1fr] items-center gap-2 py-2 pr-2 pl-2.5 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
              >
                <Combobox.ItemIndicator class="col-start-1">
                  {@render checkIcon()}
                </Combobox.ItemIndicator>
                <span class="col-start-2">{tag.label}</span>
              </Combobox.Item>
            {/snippet}
          </Combobox.Collection>
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

{#snippet crossIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
    />
  </svg>
{/snippet}

{#snippet chevronDownIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M5.75 9.5L12 15.75L18.25 9.5"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

{#snippet checkIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6 14.15L10.0321 18L18 7"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

Anatomy

<script>
  import { Combobox } from '@shardsui/svelte/combobox'
</script>

<Combobox.Root>
  <Combobox.Label />
  <Combobox.InputGroup>
    <Combobox.Chips>
      <Combobox.Chip>
        <Combobox.ChipRemove />
      </Combobox.Chip>
    </Combobox.Chips>
    <Combobox.Input />
    <Combobox.Trigger>
      <Combobox.Value />
    </Combobox.Trigger>
    <Combobox.Icon />
    <Combobox.Clear />
  </Combobox.InputGroup>

  <Combobox.Portal>
    <Combobox.Backdrop />
    <Combobox.Positioner>
      <Combobox.Popup>
        <Combobox.Arrow />
        <Combobox.Status />
        <Combobox.Empty />
        <Combobox.List>
          <Combobox.Row>
            <Combobox.Item>
              <Combobox.ItemIndicator />
            </Combobox.Item>
          </Combobox.Row>
          <Combobox.Group>
            <Combobox.GroupLabel />
          </Combobox.Group>
          <Combobox.Separator />
          <Combobox.Collection />
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

Usage guidelines

  • Combobox is a filterable Select: use it when the value is restricted to a predefined set of items (like Select) and you want to narrow that set by typing.
  • Not for free-form text: typing only filters the list — the value is always one of the items. For a search widget that accepts arbitrary text, use Autocomplete.
  • Not without an input: if you aren't rendering a text input at all, use Select — it carries the accessibility semantics for a listbox that has no input.
  • Provide an accessible name: when <Combobox.Input> is the form control, label it with a native <label> or <Field.Label>, or an aria-label when no visible label is rendered. <Combobox.Label> labels the trigger, not the input — it belongs to the input-inside-popup pattern. See the forms guide.
  • Pass items for built-in filtering: the combobox filters the items prop internally as the user types; render matches with <Combobox.Collection> inside <Combobox.List>. For async or custom filtering, pass a dynamic items array or the filteredItems / filter props — see Filtering.

TypeScript

<Combobox.Root> infers its item type from the value prop, and each entry in the items array must share that type. <Combobox.Item> is not generic — its value is unknown.

See the TypeScript guide for generic roots, typed wrappers, and bind:ref patterns.

Filtering

Pass your data with the items prop and render matches with <Combobox.Collection>:

<script lang="ts">
  import { Combobox } from '@shardsui/svelte/combobox'

  const courses = [
    { value: 'typography', label: 'Intro to Typography' },
    { value: 'spanish', label: 'Spanish for Beginners' }
    /* ... */
  ]

  let value = $state(null)
</script>

<Combobox.Root items={courses} bind:value>
  <Combobox.InputGroup>
    <Combobox.Input />
  </Combobox.InputGroup>

  <Combobox.Portal>
    <Combobox.Positioner>
      <Combobox.Popup>
        <Combobox.List>
          <Combobox.Collection>
            {#snippet children(item)}
              <Combobox.Item value={item}>
                {item.label}
              </Combobox.Item>
            {/snippet}
          </Combobox.Collection>
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

For async search or custom filtering, update the items array from your fetch handler, or pass pre-filtered data with the filteredItems prop and an optional custom filter function. See createFilter below. Or filter in the parent with $derived and render with {#each}.

Examples

Multiple select

Add the multiple prop to let people pick more than one item. <Combobox.Chips>, <Combobox.Chip>, and <Combobox.ChipRemove> render the selected values as removable chips inside the input group, and Backspace on an empty input drops the most recent one.

<script lang="ts">
  import { Combobox } from '@shardsui/svelte/combobox'

  type Topic = {
    id: string
    value: string
  }

  const id = $props.id()

  const topics: Topic[] = [
    { id: 'kerning', value: 'Kerning' },
    { id: 'contrast-ratio', value: 'Contrast ratio' },
    { id: 'flexbox', value: 'Flexbox' },
    { id: 'focus-state', value: 'Focus state' },
    { id: 'easing', value: 'Easing' },
    { id: 'design-tokens', value: 'Design tokens' }
  ]

  let value = $state<Topic[]>([])
</script>

<Combobox.Root
  multiple
  items={topics}
  bind:value
  isItemEqualToValue={(a: Topic, b: Topic) => a.id === b.id}
>
  <div class="flex max-w-md flex-col gap-1 text-sm/5 font-semibold text-gray-900">
    <label for={id}>Topics</label>
    <Combobox.InputGroup
      class="flex min-h-8 w-64 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-gray-950 min-[500px]:w-88"
    >
      <Combobox.Chips class="flex w-full flex-wrap items-center gap-1">
        {#each value as topic (topic.id)}
          <Combobox.Chip
            aria-label={topic.value}
            class="flex min-h-5.5 items-center gap-1 rounded-md bg-gray-100 py-0 pr-1 pl-2 text-sm text-gray-900 outline-hidden focus-within:bg-gray-950 focus-within:text-gray-50"
          >
            {topic.value}
            <Combobox.ChipRemove
              class="flex size-4 items-center justify-center rounded-md p-0 text-inherit hover:bg-gray-200"
              aria-label={`Remove ${topic.value}`}
            >
              {@render crossIcon()}
            </Combobox.ChipRemove>
          </Combobox.Chip>
        {/each}
        <Combobox.Input
          {id}
          placeholder={value.length > 0 ? '' : 'e.g. Kerning'}
          class="h-5.5 min-w-12 flex-1 rounded-md border-0 bg-transparent p-0 text-sm font-normal text-gray-900 outline-hidden any-pointer-coarse:text-base"
        />
      </Combobox.Chips>
    </Combobox.InputGroup>
  </div>

  <Combobox.Portal>
    <Combobox.Positioner class="z-50 outline-hidden" sideOffset={4}>
      <Combobox.Popup
        class="max-h-[min(var(--available-height),24.5rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-py-1 overflow-y-auto overscroll-contain rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
      >
        <Combobox.Empty>
          <div class="py-2 pr-4 pl-2 text-sm/4 text-gray-600">No topics found.</div>
        </Combobox.Empty>
        <Combobox.List>
          <Combobox.Collection>
            {#snippet children(item)}
              {@const topic = item as Topic}
              <Combobox.Item
                value={topic}
                class="grid grid-cols-[1rem_1fr] items-center gap-2 py-2 pr-2 pl-2.5 text-sm/4 outline-hidden select-none [@media(hover:hover)]:data-highlighted:relative [@media(hover:hover)]:data-highlighted:z-0 [@media(hover:hover)]:data-highlighted:text-gray-50 [@media(hover:hover)]:data-highlighted:before:absolute [@media(hover:hover)]:data-highlighted:before:inset-x-1 [@media(hover:hover)]:data-highlighted:before:inset-y-0 [@media(hover:hover)]:data-highlighted:before:z-[-1] [@media(hover:hover)]:data-highlighted:before:rounded-sm [@media(hover:hover)]:data-highlighted:before:bg-gray-900"
              >
                <Combobox.ItemIndicator class="col-start-1">
                  {@render checkIcon()}
                </Combobox.ItemIndicator>
                <span class="col-start-2">{topic.value}</span>
              </Combobox.Item>
            {/snippet}
          </Combobox.Collection>
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

{#snippet crossIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
    />
  </svg>
{/snippet}

{#snippet checkIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6 14.15L10.0321 18L18 7"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

To keep a long selection from overflowing, slice the values you render inside <Combobox.Chips> and show the rest as a count:

<script>
  const CHIP_LIMIT = 3
  const visibleValue = $derived(value.slice(0, CHIP_LIMIT))
  const hiddenCount = $derived(value.length - visibleValue.length)
</script>

<Combobox.Chips>
  {#each visibleValue as item (item)}
    <Combobox.Chip>
      {item}
      <Combobox.ChipRemove aria-label={`Remove ${item}`} />
    </Combobox.Chip>
  {/each}
  {#if hiddenCount > 0}
    <span>
      {`+${hiddenCount} more`}
    </span>
  {/if}
  <Combobox.Input />
</Combobox.Chips>

Grouped

Wrap related items in a <Combobox.Group> with a <Combobox.GroupLabel> heading. Filtering runs within each group, and a group whose items all filter out disappears on its own.

Model each group as one object: an items array of its entries, plus any extra field (value here) that you read when rendering the label.

<script lang="ts">
  type TopicGroup = {
    value: string
    items: string[]
  }

  const groups: TopicGroup[] = [
    { value: 'Design', items: ['Typography', 'Color theory', 'Layout'] },
    { value: 'Programming', items: ['JavaScript', 'Python', 'Databases'] }
  ]
</script>
<script lang="ts">
  import { Combobox } from '@shardsui/svelte/combobox'

  type Topic = {
    id: string
    label: string
  }

  type TopicGroup = {
    value: string
    items: Topic[]
  }

  const id = $props.id()

  const topicGroups: TopicGroup[] = [
    {
      value: 'Design',
      items: [
        { id: 'kerning', label: 'Kerning' },
        { id: 'contrast-ratio', label: 'Contrast ratio' },
        { id: 'easing', label: 'Easing' }
      ]
    },
    {
      value: 'Frontend',
      items: [
        { id: 'flexbox', label: 'Flexbox' },
        { id: 'html', label: 'HTML' },
        { id: 'css', label: 'CSS' },
        { id: 'javascript', label: 'JavaScript' }
      ]
    }
  ]
</script>

<Combobox.Root items={topicGroups}>
  <div class="relative flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
    <label for={id}>Pick a topic</label>
    <Combobox.InputGroup
      class="relative box-content h-8 w-64 rounded-md border border-gray-200 bg-gray-50 focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-gray-950 [&>input]:pr-8 has-[.combobox-clear]:[&>input]:pr-[calc(0.5rem+1.5rem*2)]"
    >
      <Combobox.Input
        {id}
        placeholder="e.g. CSS"
        class="size-full border-0 bg-transparent pl-2 text-sm font-normal text-gray-900 outline-hidden any-pointer-coarse:text-base"
      />
      <div class="absolute right-1 bottom-0 flex h-8 items-center justify-center text-gray-600">
        <Combobox.Clear
          class="combobox-clear flex h-8 w-6 items-center justify-center rounded bg-transparent p-0"
          aria-label="Clear selection"
        >
          {@render crossIcon()}
        </Combobox.Clear>
        <Combobox.Trigger
          class="flex h-8 w-6 items-center justify-center rounded bg-transparent p-0"
          aria-label="Open popup"
        >
          {@render chevronDownIcon()}
        </Combobox.Trigger>
      </div>
    </Combobox.InputGroup>
  </div>

  <Combobox.Portal>
    <Combobox.Positioner class="outline-hidden" sideOffset={4}>
      <Combobox.Popup
        class="max-h-92 w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-md bg-gray-50 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
      >
        <Combobox.Empty>
          <div class="py-4 pr-4 pl-2 text-sm/4 text-gray-600">No topics found.</div>
        </Combobox.Empty>
        <Combobox.List
          class="max-h-[min(22.5rem,var(--available-height))] scroll-pt-9 scroll-pb-1 overflow-y-auto overscroll-contain outline-0"
        >
          <Combobox.Collection>
            {#snippet children(item)}
              {@const group = item as TopicGroup}
              <Combobox.Group items={group.items} class="pb-2">
                <Combobox.GroupLabel
                  class="sticky top-0 z-1 mr-2 w-[calc(100%-0.5rem)] bg-gray-50 py-2 pr-2 pl-8 text-xs font-semibold tracking-wider uppercase"
                >
                  {group.value}
                </Combobox.GroupLabel>
                <Combobox.Collection>
                  {#snippet children(groupItem)}
                    {@const topic = groupItem as Topic}
                    <Combobox.Item
                      value={topic}
                      class="grid grid-cols-[1rem_1fr] items-center gap-2 py-2 pr-2 pl-2.5 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
                    >
                      <Combobox.ItemIndicator class="col-start-1 flex items-center justify-center">
                        {@render checkIcon()}
                      </Combobox.ItemIndicator>
                      <span class="col-start-2">{topic.label}</span>
                    </Combobox.Item>
                  {/snippet}
                </Combobox.Collection>
              </Combobox.Group>
            {/snippet}
          </Combobox.Collection>
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

{#snippet crossIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
    />
  </svg>
{/snippet}

{#snippet chevronDownIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M5.75 9.5L12 15.75L18.25 9.5"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

{#snippet checkIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6 14.15L10.0321 18L18 7"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

Input inside popup

Render the <Combobox.Input> inside the popup itself. Useful when the trigger is a button-like control and the search field appears only when opened.

Assignee
<script lang="ts">
  import { Combobox } from '@shardsui/svelte/combobox'

  type Person = {
    value: string
    label: string
  }

  const people: Person[] = [
    { value: 'rand', label: 'Paul Rand' },
    { value: 'bass', label: 'Saul Bass' },
    { value: 'glaser', label: 'Milton Glaser' },
    { value: 'vignelli', label: 'Massimo Vignelli' },
    { value: 'scher', label: 'Paula Scher' },
    { value: 'rams', label: 'Dieter Rams' },
    { value: 'sagmeister', label: 'Stefan Sagmeister' }
  ]
</script>

<div class="flex flex-col gap-1">
  <Combobox.Root items={people} isItemEqualToValue={(a: Person, b: Person) => a.value === b.value}>
    <Combobox.Label class="text-sm/5 font-semibold text-gray-900">Assignee</Combobox.Label>
    <Combobox.Trigger
      class="flex h-8 min-w-40 items-center justify-between gap-3 rounded-md border border-gray-200 bg-gray-50 pr-2 pl-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 data-popup-open:bg-gray-100"
    >
      <Combobox.Value>
        {#snippet children(v)}
          {#if v}
            {(v as Person).label}
          {:else}
            <span class="opacity-60">Select assignee</span>
          {/if}
        {/snippet}
      </Combobox.Value>
      <Combobox.Icon class="flex">
        {@render chevronDownIcon()}
      </Combobox.Icon>
    </Combobox.Trigger>
    <Combobox.Portal>
      <Combobox.Positioner class="z-10 outline-hidden" sideOffset={8}>
        <Combobox.Popup
          class="group min-w-(--anchor-width) origin-(--transform-origin) rounded-md bg-gray-50 bg-clip-padding text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Combobox.Input
            class="box-border w-full border-0 border-b border-gray-200 bg-transparent px-3 py-2 text-sm font-normal text-gray-900 outline-hidden placeholder:text-gray-500 any-pointer-coarse:text-base"
            placeholder="Search…"
            aria-label="Select assignee"
          />
          <Combobox.Empty>
            <div class="py-4 pr-4 pl-2 text-sm/4 text-gray-600">No results found.</div>
          </Combobox.Empty>
          <Combobox.List
            class="max-h-[min(22.5rem,var(--available-height))] scroll-py-1 overflow-y-auto overscroll-contain py-1 outline-0 data-empty:p-0"
          >
            <Combobox.Collection>
              {#snippet children(item)}
                {@const person = item as Person}
                <Combobox.Item
                  value={person}
                  class="grid grid-cols-[1rem_1fr] items-center gap-2 py-2 pr-2 pl-2.5 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
                >
                  <Combobox.ItemIndicator class="col-start-1">
                    {@render checkIcon()}
                  </Combobox.ItemIndicator>
                  <span class="col-start-2">{person.label}</span>
                </Combobox.Item>
              {/snippet}
            </Combobox.Collection>
          </Combobox.List>
        </Combobox.Popup>
      </Combobox.Positioner>
    </Combobox.Portal>
  </Combobox.Root>
</div>

{#snippet chevronDownIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M5.75 9.5L12 15.75L18.25 9.5"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

{#snippet checkIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6 14.15L10.0321 18L18 7"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

Here the trigger is the form control, so label it with <Combobox.Label>. It renders a <div>, so a click lands focus on the trigger without opening the popup:

<Combobox.Root>
  <Combobox.Label>Instructor</Combobox.Label>
  ...
</Combobox.Root>

Async search (single)

Fetch items as the user types, so nothing loads upfront. Keep the currently selected item in the items array while new results stream in, otherwise the selection drops out of the list mid-fetch. Use <Combobox.Status> to announce loading and <Combobox.Empty> for the no-results state.

<script lang="ts">
  import { Combobox } from '@shardsui/svelte/combobox'

  type Product = {
    id: string
    name: string
    description: string
  }

  const { contains } = Combobox.createFilter()

  const id = $props.id()

  const catalog: Product[] = [
    {
      id: 'typography',
      name: 'Typography',
      description: 'Kerning, tracking, and type scale'
    },
    {
      id: 'color',
      name: 'Color',
      description: 'Contrast ratio, OKLCH, and semantic tokens'
    },
    {
      id: 'layout',
      name: 'Layout',
      description: 'Flexbox, grid, and negative space'
    },
    {
      id: 'motion',
      name: 'Motion',
      description: 'Ease-out, duration, and reduced motion'
    },
    {
      id: 'tokens',
      name: 'Tokens',
      description: 'Name your design decisions once, reuse everywhere'
    },
    { id: 'data-viz', name: 'Data viz', description: 'Turn numbers into clear stories' }
  ]

  let results = $state<Product[]>([])
  let selected = $state<Product | null>(null)
  let query = $state('')
  let pending = $state(false)
  let controller: AbortController | null = null

  const trimmed = $derived(query.trim())

  const items = $derived.by(() => {
    const current = selected
    if (!current || results.some((product) => product.id === current.id)) return results
    return [...results, current]
  })

  function search(value: string): Promise<Product[]> {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(
          catalog.filter(
            (product) => contains(product.name, value) || contains(product.description, value)
          )
        )
      }, 400)
    })
  }

  function searchCatalog(value: string) {
    controller?.abort()

    if (selected && value === selected.name) {
      pending = false
      return
    }

    if (!value.trim()) {
      results = []
      pending = false
      return
    }

    controller = new AbortController()
    const signal = controller.signal
    pending = true
    search(value).then((found) => {
      if (signal.aborted) return
      results = found
      pending = false
    })
  }
</script>

<Combobox.Root
  {items}
  bind:value={selected}
  bind:inputValue={query}
  itemToStringLabel={(p: Product) => p.name}
  isItemEqualToValue={(a: Product, b: Product) => a.id === b.id}
  filter={null}
  onInputValueChange={searchCatalog}
  onOpenChangeComplete={(open) => {
    if (!open && selected) results = [selected]
  }}
>
  <div class="relative flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
    <label for={id}>Find product</label>
    <Combobox.InputGroup
      class="relative box-content h-8 w-64 rounded-md border border-gray-200 bg-gray-50 focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-gray-950 md:w-80 [&>input]:pr-8 has-[.combobox-clear]:[&>input]:pr-[calc(0.5rem+1.5rem*2)]"
    >
      <Combobox.Input
        {id}
        placeholder="Search products…"
        class="box-border size-full border-0 bg-transparent pl-2 text-sm font-normal text-gray-900 outline-hidden any-pointer-coarse:text-base"
      />
      <div class="absolute right-1 bottom-0 flex h-8 items-center justify-center text-gray-600">
        <Combobox.Clear
          class="combobox-clear flex h-8 w-6 items-center justify-center rounded border-0 bg-transparent p-0"
          aria-label="Clear selection"
        >
          {@render crossIcon()}
        </Combobox.Clear>
        <Combobox.Trigger
          class="flex h-8 w-6 items-center justify-center rounded border-0 bg-transparent p-0"
          aria-label="Open popup"
        >
          {@render chevronDownIcon()}
        </Combobox.Trigger>
      </div>
    </Combobox.InputGroup>
  </div>

  <Combobox.Portal>
    <Combobox.Positioner class="outline-hidden" sideOffset={4}>
      <Combobox.Popup
        class="box-border max-h-[min(var(--available-height),22.5rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-py-1 overflow-y-auto overscroll-contain rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 data-ending-style:transition-none data-starting-style:scale-95 data-starting-style:opacity-0"
        aria-busy={pending || undefined}
      >
        <Combobox.Status>
          {#if pending}
            <div class="flex items-center gap-2 py-1 pr-5 pl-2 text-sm text-gray-600">
              <span
                aria-hidden="true"
                class="inline-block size-3 animate-spin rounded-full border border-current border-r-transparent"
              ></span>
              Searching…
            </div>
          {:else if trimmed === '' && !selected}
            <div class="flex items-center gap-2 py-1 pr-5 pl-2 text-sm text-gray-600">
              Start typing to search…
            </div>
          {/if}
        </Combobox.Status>
        <Combobox.Empty>
          {#if trimmed !== '' && !pending}
            <div class="py-2 pr-4 pl-2 text-sm/4 text-gray-600">No results found.</div>
          {/if}
        </Combobox.Empty>
        <Combobox.List>
          <Combobox.Collection>
            {#snippet children(item)}
              {@const product = item as Product}
              <Combobox.Item
                value={product}
                class="grid grid-cols-[1rem_1fr] items-start gap-2 py-2 pr-2 pl-2.5 text-sm/[1.2rem] outline-hidden select-none [@media(hover:hover)]:data-highlighted:relative [@media(hover:hover)]:data-highlighted:z-0 [@media(hover:hover)]:data-highlighted:before:absolute [@media(hover:hover)]:data-highlighted:before:inset-x-1 [@media(hover:hover)]:data-highlighted:before:inset-y-0 [@media(hover:hover)]:data-highlighted:before:z-[-1] [@media(hover:hover)]:data-highlighted:before:rounded [@media(hover:hover)]:data-highlighted:before:bg-gray-100"
              >
                <Combobox.ItemIndicator class="col-start-1 mt-1">
                  {@render checkIcon()}
                </Combobox.ItemIndicator>
                <span class="col-start-2 flex flex-col gap-0.5">
                  <span class="text-sm font-semibold">{product.name}</span>
                  <span class="text-xs text-gray-600">{product.description}</span>
                </span>
              </Combobox.Item>
            {/snippet}
          </Combobox.Collection>
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

{#snippet crossIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
    />
  </svg>
{/snippet}

{#snippet chevronDownIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M5.75 9.5L12 15.75L18.25 9.5"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

{#snippet checkIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6 14.15L10.0321 18L18 7"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

Async search (multiple)

Fetch on input changes while allowing several selections. Merge the already-selected items into the items array so their chips stay valid as new matches stream in, and clear the query after each pick so the next search starts fresh.

<script lang="ts">
  import { Combobox } from '@shardsui/svelte/combobox'

  type Person = {
    id: string
    name: string
    role: string
  }

  const { contains } = Combobox.createFilter()

  const id = $props.id()

  const directory: Person[] = [
    { id: 'rand', name: 'Paul Rand', role: 'Identity' },
    { id: 'bass', name: 'Saul Bass', role: 'Motion' },
    { id: 'glaser', name: 'Milton Glaser', role: 'Illustration' },
    { id: 'vignelli', name: 'Massimo Vignelli', role: 'Typography' },
    { id: 'scher', name: 'Paula Scher', role: 'Identity' },
    { id: 'rams', name: 'Dieter Rams', role: 'Industrial' },
    { id: 'sagmeister', name: 'Stefan Sagmeister', role: 'Editorial' },
    { id: 'aicher', name: 'Otl Aicher', role: 'Systems' }
  ]

  let results = $state<Person[]>([])
  let value = $state<Person[]>([])
  let query = $state('')
  let pending = $state(false)
  let controller: AbortController | null = null

  const trimmed = $derived(query.trim())

  const items = $derived.by(() => {
    if (value.length === 0) return results
    const merged = [...results]
    for (const person of value) {
      if (!merged.some((r) => r.id === person.id)) merged.push(person)
    }
    return merged
  })

  function search(text: string, signal: AbortSignal): Promise<Person[]> {
    return new Promise((resolve) => {
      setTimeout(() => {
        if (signal.aborted) return resolve([])
        resolve(directory.filter((p) => contains(p.name, text) || contains(p.role, text)))
      }, 400)
    })
  }

  function searchDirectory(next: string) {
    controller?.abort()

    if (!next.trim()) {
      results = []
      pending = false
      return
    }

    controller = new AbortController()
    const signal = controller.signal
    pending = true
    search(next, signal).then((found) => {
      if (signal.aborted) return
      results = found
      pending = false
    })
  }
</script>

<Combobox.Root
  multiple
  {items}
  bind:value
  bind:inputValue={query}
  itemToStringLabel={(p: Person) => p.name}
  isItemEqualToValue={(a: Person, b: Person) => a.id === b.id}
  filter={null}
  onInputValueChange={searchDirectory}
  onValueChange={(next) => {
    query = ''
    if (!next?.length) results = []
  }}
  onOpenChangeComplete={(open) => {
    if (!open) results = []
  }}
>
  <div class="flex max-w-md flex-col gap-1 text-sm/5 font-semibold text-gray-900">
    <label for={id}>Collaborators</label>
    <Combobox.InputGroup
      class="flex min-h-8 w-64 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-gray-950 min-[500px]:w-88"
    >
      <Combobox.Chips class="flex w-full flex-wrap items-center gap-1">
        {#each value as person (person.id)}
          <Combobox.Chip
            aria-label={person.name}
            class="flex min-h-5.5 items-center gap-1 rounded-md bg-gray-100 py-0 pr-1 pl-2 text-sm text-gray-900 outline-hidden focus-within:bg-gray-950 focus-within:text-gray-50"
          >
            {person.name}
            <Combobox.ChipRemove
              class="flex size-4 items-center justify-center rounded-md p-0 text-inherit hover:bg-gray-200"
              aria-label={`Remove ${person.name}`}
            >
              {@render crossIcon()}
            </Combobox.ChipRemove>
          </Combobox.Chip>
        {/each}
        <Combobox.Input
          {id}
          placeholder={value.length > 0 ? '' : 'Search people…'}
          class="h-5.5 min-w-12 flex-1 rounded-md border-0 bg-transparent p-0 text-sm font-normal text-gray-900 outline-hidden any-pointer-coarse:text-base"
        />
      </Combobox.Chips>
    </Combobox.InputGroup>
  </div>

  <Combobox.Portal>
    <Combobox.Positioner class="z-50 outline-hidden" sideOffset={4}>
      <Combobox.Popup
        class="max-h-[min(var(--available-height),24.5rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-py-1 overflow-y-auto overscroll-contain rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
        aria-busy={pending || undefined}
      >
        <Combobox.Status>
          {#if pending}
            <div class="flex items-center gap-2 py-1 pr-5 pl-2 text-sm text-gray-600">
              <span
                aria-hidden="true"
                class="inline-block size-3 animate-spin rounded-full border border-current border-r-transparent"
              ></span>
              Searching…
            </div>
          {:else if trimmed === '' && value.length === 0}
            <div class="flex items-center gap-2 py-1 pr-5 pl-2 text-sm text-gray-600">
              Start typing to search people…
            </div>
          {/if}
        </Combobox.Status>
        <Combobox.Empty>
          {#if trimmed !== '' && !pending}
            <div class="py-2 pr-4 pl-2 text-sm/4 text-gray-600">No people found.</div>
          {/if}
        </Combobox.Empty>
        <Combobox.List>
          <Combobox.Collection>
            {#snippet children(item)}
              {@const person = item as Person}
              <Combobox.Item
                value={person}
                class="grid grid-cols-[1rem_1fr] items-start gap-2 py-2 pr-2 pl-2.5 text-sm/[1.2rem] outline-hidden select-none [@media(hover:hover)]:data-highlighted:relative [@media(hover:hover)]:data-highlighted:z-0 [@media(hover:hover)]:data-highlighted:before:absolute [@media(hover:hover)]:data-highlighted:before:inset-x-1 [@media(hover:hover)]:data-highlighted:before:inset-y-0 [@media(hover:hover)]:data-highlighted:before:z-[-1] [@media(hover:hover)]:data-highlighted:before:rounded [@media(hover:hover)]:data-highlighted:before:bg-gray-100"
              >
                <Combobox.ItemIndicator class="col-start-1 mt-1">
                  {@render checkIcon()}
                </Combobox.ItemIndicator>
                <span class="col-start-2 flex flex-col gap-0.5">
                  <span class="text-sm font-semibold">{person.name}</span>
                  <span class="text-xs text-gray-600">{person.role}</span>
                </span>
              </Combobox.Item>
            {/snippet}
          </Combobox.Collection>
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

{#snippet crossIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
    />
  </svg>
{/snippet}

{#snippet checkIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6 14.15L10.0321 18L18 7"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

Creatable

Surface a "Create …" affordance when the typed value doesn't match any existing item. Selecting it opens a dialog to name and confirm the new item before it's added.

<script lang="ts">
  import { Combobox } from '@shardsui/svelte/combobox'
  import { Dialog } from '@shardsui/svelte/dialog'

  type Tag = {
    id: string
    value: string
    creatable?: string
  }

  const id = $props.id()

  let tags = $state<Tag[]>([
    { id: 'kerning', value: 'kerning' },
    { id: 'tracking', value: 'tracking' },
    { id: 'leading', value: 'leading' }
  ])
  let selected = $state<Tag[]>([])
  let query = $state('')
  let openDialog = $state(false)
  let draftName = $state('')
  let createInputEl = $state<HTMLInputElement | null>(null)
  let highlighted: Tag | undefined

  const trimmed = $derived(query.trim())
  const match = $derived(findTag(trimmed))

  const items = $derived<Tag[]>(
    trimmed !== '' && !match
      ? [...tags, { id: `create:${trimmed}`, value: trimmed, creatable: trimmed }]
      : tags
  )

  function findTag(value: string) {
    const lower = value.toLowerCase()
    return tags.find((t) => t.value.toLowerCase() === lower)
  }

  function addTag(tag: Tag) {
    if (!selected.some((s) => s.id === tag.id)) selected = [...selected, tag]
  }

  function commitSelection(next: Tag[] | null) {
    const chosen = next ?? []
    const draft = chosen.find((t) => t.creatable && !selected.some((s) => s.id === t.id))?.creatable

    if (draft) {
      draftName = draft
      openDialog = true
      return
    }

    selected = chosen.filter((t) => !t.creatable)
    query = ''
  }

  function onkeydown(event: KeyboardEvent) {
    if (event.key !== 'Enter' || highlighted || !trimmed) return

    if (match) {
      addTag(match)
      query = ''
      return
    }

    draftName = trimmed
    openDialog = true
  }

  function createTag(event: SubmitEvent) {
    event.preventDefault()
    const value = draftName.trim()
    if (!value) return

    const existing = findTag(value)
    if (existing) {
      addTag(existing)
    } else {
      const base = value.toLowerCase().replace(/\s+/g, '-')
      let id = base
      for (let i = 2; tags.some((t) => t.id === id); i += 1) id = `${base}-${i}`

      const tag: Tag = { id, value }
      tags = [...tags, tag]
      addTag(tag)
    }

    openDialog = false
    query = ''
  }
</script>

<Combobox.Root
  {items}
  bind:value={() => selected, commitSelection}
  bind:inputValue={query}
  multiple
  isItemEqualToValue={(a: Tag, b: Tag) => a.id === b.id}
  onItemHighlighted={(item) => (highlighted = item as Tag | undefined)}
>
  <div class="flex max-w-md flex-col gap-1 text-sm/5 font-semibold text-gray-900">
    <label for={id}>Tags</label>
    <Combobox.InputGroup
      class="min-h-8 w-64 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-gray-950 min-[500px]:w-88"
    >
      <Combobox.Chips class="flex w-full flex-wrap items-center gap-1">
        {#each selected as tag (tag.id)}
          <Combobox.Chip
            aria-label={tag.value}
            class="flex min-h-5.5 items-center gap-1 rounded-md bg-gray-100 py-0 pr-1 pl-2 text-sm text-gray-900 outline-hidden focus-within:bg-gray-950 focus-within:text-gray-50"
          >
            {tag.value}
            <Combobox.ChipRemove
              class="flex size-4 items-center justify-center rounded-md p-0 text-inherit hover:bg-gray-200"
              aria-label={`Remove ${tag.value}`}
            >
              {@render crossIcon()}
            </Combobox.ChipRemove>
          </Combobox.Chip>
        {/each}
        <Combobox.Input
          {id}
          {onkeydown}
          placeholder={selected.length > 0 ? '' : 'e.g. kerning'}
          class="h-5.5 min-w-12 flex-1 rounded-md border-0 bg-transparent p-0 text-sm font-normal text-gray-900 outline-hidden any-pointer-coarse:text-base"
        />
      </Combobox.Chips>
    </Combobox.InputGroup>
  </div>

  <Combobox.Portal>
    <Combobox.Positioner class="z-50 outline-hidden" sideOffset={4}>
      <Combobox.Popup
        class="max-h-[min(var(--available-height),24.5rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-py-1 overflow-y-auto overscroll-contain rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] duration-100 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
      >
        <Combobox.Empty>
          <div class="py-2 pr-4 pl-2 text-sm/4 text-gray-600">No tags found.</div>
        </Combobox.Empty>
        <Combobox.List>
          <Combobox.Collection>
            {#snippet children(item)}
              {@const tag = item as Tag}
              <Combobox.Item
                value={tag}
                class="grid grid-cols-[1rem_1fr] items-center gap-2 py-2 pr-2 pl-2.5 text-sm/4 outline-hidden select-none [@media(hover:hover)]:data-highlighted:relative [@media(hover:hover)]:data-highlighted:z-0 [@media(hover:hover)]:data-highlighted:text-gray-50 [@media(hover:hover)]:data-highlighted:before:absolute [@media(hover:hover)]:data-highlighted:before:inset-x-1 [@media(hover:hover)]:data-highlighted:before:inset-y-0 [@media(hover:hover)]:data-highlighted:before:z-[-1] [@media(hover:hover)]:data-highlighted:before:rounded-sm [@media(hover:hover)]:data-highlighted:before:bg-gray-900"
              >
                {#if tag.creatable}
                  <span class="col-start-1">
                    {@render plusIcon()}
                  </span>
                  <span class="col-start-2">Create "{tag.creatable}"</span>
                {:else}
                  <Combobox.ItemIndicator class="col-start-1">
                    {@render checkIcon()}
                  </Combobox.ItemIndicator>
                  <span class="col-start-2">{tag.value}</span>
                {/if}
              </Combobox.Item>
            {/snippet}
          </Combobox.Collection>
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

<Dialog.Root bind:open={openDialog}>
  <Dialog.Portal>
    <Dialog.Backdrop
      class="fixed inset-0 min-h-dvh bg-black opacity-20 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute"
    />
    <Dialog.Popup
      initialFocus={() => createInputEl}
      class="fixed top-1/2 left-1/2 w-80 max-w-[calc(100vw-3rem)] -translate-1/2 rounded-lg bg-gray-50 p-4 text-gray-900 outline-1 outline-gray-200 transition-[scale,opacity] duration-100 ease-out data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-starting-style:scale-[0.98] data-starting-style:opacity-0"
    >
      <Dialog.Title class="mb-1 text-base font-semibold">Create tag</Dialog.Title>
      <Dialog.Description class="mb-4 text-sm text-gray-600">
        Add a new tag to select.
      </Dialog.Description>
      <form onsubmit={createTag}>
        <input
          bind:this={createInputEl}
          bind:value={draftName}
          placeholder="Tag name"
          class="h-8 w-full rounded-md border border-gray-200 px-2 text-sm font-normal text-gray-900 focus:outline-2 focus:-outline-offset-1 focus:outline-gray-950 any-pointer-coarse:text-base"
        />
        <div class="mt-4 flex justify-end gap-3">
          <Dialog.Close
            class="flex h-8 items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-3 text-sm font-normal text-gray-900 select-none hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-100"
          >
            Cancel
          </Dialog.Close>
          <button
            type="submit"
            class="flex h-8 items-center justify-center rounded-md border border-gray-900 bg-gray-900 px-3 text-sm font-normal text-gray-50 select-none hover:bg-gray-700 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 active:bg-gray-700"
          >
            Create
          </button>
        </div>
      </form>
    </Dialog.Popup>
  </Dialog.Portal>
</Dialog.Root>

{#snippet crossIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
    />
  </svg>
{/snippet}

{#snippet plusIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M12 3.75V12M12 12V20.25M12 12H3.75M12 12H20.25"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

{#snippet checkIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6 14.15L10.0321 18L18 7"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

Virtualized

For large datasets, renders only the visible items.

<script lang="ts">
  import { Combobox } from '@shardsui/svelte/combobox'

  type Item = {
    id: string
    name: string
  }

  const ROW_HEIGHT = 32
  const VISIBLE = 12
  const OVERSCAN = 8

  const items: Item[] = Array.from({ length: 10_000 }, (_, i) => {
    const id = String(i + 1)
    return { id, name: `Item ${id.padStart(4, '0')}` }
  })

  const filter = Combobox.createFilter()
  const id = $props.id()

  let inputValue = $state('')

  let scrollEl = $state<HTMLElement | null>(null)
  let scrollTop = $state(0)

  const filteredItems = $derived(
    inputValue.trim() === ''
      ? items
      : items.filter((item) => filter.contains(item.name, inputValue))
  )

  const count = $derived(filteredItems.length)
  const totalHeight = $derived(count * ROW_HEIGHT)
  const start = $derived(Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN))
  const end = $derived(Math.min(count, start + VISIBLE + OVERSCAN * 2))
  const offsetTop = $derived(start * ROW_HEIGHT)
  const slice = $derived(filteredItems.slice(start, end))

  function scrollHighlightedIntoView(
    item: Item | undefined,
    reason: 'keyboard' | 'pointer' | 'none',
    index: number
  ) {
    if (reason === 'pointer') return
    if (!item || !scrollEl) return
    const top = index * ROW_HEIGHT
    const bottom = top + ROW_HEIGHT
    if (top < scrollEl.scrollTop) {
      scrollEl.scrollTop = top
    } else if (bottom > scrollEl.scrollTop + scrollEl.clientHeight) {
      scrollEl.scrollTop = bottom - scrollEl.clientHeight
    }
  }

  function onscroll(event: Event) {
    scrollTop = (event.target as HTMLElement).scrollTop
  }
</script>

<Combobox.Root
  virtualized
  bind:inputValue
  {filteredItems}
  filter={null}
  isItemEqualToValue={(a: Item, b: Item) => a.id === b.id}
  itemToStringLabel={(item) => (item ? item.name : '')}
  onItemHighlighted={scrollHighlightedIntoView}
>
  <div class="relative flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
    <label for={id}>Search 10,000 items</label>
    <Combobox.InputGroup
      class="relative box-content h-8 w-64 rounded-md border border-gray-200 bg-gray-50 focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-gray-950 [&>input]:pr-8 has-[.combobox-clear]:[&>input]:pr-[calc(0.5rem+1.5rem*2)]"
    >
      <Combobox.Input
        {id}
        class="size-full border-0 bg-transparent pl-2 text-sm font-normal text-gray-900 outline-hidden any-pointer-coarse:text-base"
      />
      <div class="absolute right-1 bottom-0 flex h-8 items-center justify-center text-gray-600">
        <Combobox.Clear
          class="combobox-clear flex h-8 w-6 items-center justify-center rounded bg-transparent p-0"
          aria-label="Clear selection"
        >
          {@render crossIcon()}
        </Combobox.Clear>
        <Combobox.Trigger
          class="flex h-8 w-6 items-center justify-center rounded bg-transparent p-0"
          aria-label="Open popup"
        >
          {@render chevronDownIcon()}
        </Combobox.Trigger>
      </div>
    </Combobox.InputGroup>
  </div>

  <Combobox.Portal>
    <Combobox.Positioner class="outline-hidden" sideOffset={4}>
      <Combobox.Popup
        class="max-h-[min(22rem,var(--available-height))] w-(--anchor-width) max-w-(--available-width) rounded-md bg-gray-50 text-gray-900 shadow-lg outline-1 outline-gray-200"
      >
        <Combobox.Empty>
          <div class="px-2 py-3 text-sm/4 text-gray-600">No results found.</div>
        </Combobox.Empty>
        <Combobox.List class="p-0">
          <div
            role="presentation"
            bind:this={scrollEl}
            class="h-[min(22.5rem,var(--total-size))] max-h-(--available-height) scroll-py-1 overflow-auto overscroll-contain"
            style:--total-size="{totalHeight}px"
            {onscroll}
          >
            <div role="presentation" class="relative w-full" style:height="{totalHeight}px">
              <div role="presentation" class="absolute inset-x-0" style:top="{offsetTop}px">
                {#each slice as item, i (item.id)}
                  <Combobox.Item
                    index={start + i}
                    value={item}
                    aria-setsize={count}
                    aria-posinset={start + i + 1}
                    class="grid grid-cols-[1rem_1fr] items-center gap-2 py-2 pr-2 pl-2.5 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900"
                    style="height:{ROW_HEIGHT}px;"
                  >
                    <Combobox.ItemIndicator class="col-start-1">
                      {@render checkIcon()}
                    </Combobox.ItemIndicator>
                    <span class="col-start-2">{item.name}</span>
                  </Combobox.Item>
                {/each}
              </div>
            </div>
          </div>
        </Combobox.List>
      </Combobox.Popup>
    </Combobox.Positioner>
  </Combobox.Portal>
</Combobox.Root>

{#snippet crossIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6.25 6.25L17.75 17.75M17.75 6.25L6.25 17.75"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
    />
  </svg>
{/snippet}

{#snippet chevronDownIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M5.75 9.5L12 15.75L18.25 9.5"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

{#snippet checkIcon()}
  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="size-4">
    <path
      d="M6 14.15L10.0321 18L18 7"
      stroke="currentColor"
      stroke-width="1.5"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
  </svg>
{/snippet}

API reference

Root

Groups all parts of the combobox. Doesn't render its own HTML element, but renders a hidden <input> beside — one per selected value in multiple mode.

PropTypeDefault

Label

An accessible label that is automatically associated with the combobox trigger. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-validPresent when the field is valid (when wrapped in Field.Root).
data-invalidPresent when the field is invalid (when wrapped in Field.Root).
data-touchedPresent when the field has been touched (when wrapped in Field.Root).
data-dirtyPresent when the field's value has changed (when wrapped in Field.Root).
data-filledPresent when the combobox has a value (when wrapped in Field.Root).
data-focusedPresent when the control is focused (when wrapped in Field.Root).

InputGroup

A wrapper for the input and its associated controls. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-popup-openPresent when the popup is open.
data-pressedPresent when the input group is pressed.
data-disabledPresent when the combobox is disabled.
data-readonlyPresent when the combobox is read-only.
data-popup-sideIndicates which side the popup is positioned relative to its anchor.
data-validPresent when the field is valid (when wrapped in Field.Root).
data-invalidPresent when the field is invalid (when wrapped in Field.Root).
data-touchedPresent when the field has been touched (when wrapped in Field.Root).
data-dirtyPresent when the field's value has changed (when wrapped in Field.Root).
data-filledPresent when the combobox has a value (when wrapped in Field.Root).
data-focusedPresent when the combobox is focused (when wrapped in Field.Root).
data-list-emptyPresent when no items are rendered.
data-placeholderPresent when the combobox has no value.

Input

A text input to search for items in the list. Renders an <input> element.

PropTypeDefault

Read-only and required behavior come from <Combobox.Root>'s readOnly and required props.

AttributeDescription
data-popup-openPresent when the popup is open.
data-pressedPresent when the input is pressed.
data-disabledPresent when the input is disabled.
data-readonlyPresent when the input is read-only.
data-popup-sideIndicates which side the popup is positioned relative to its anchor.
data-validPresent when the field is valid (when wrapped in Field.Root).
data-invalidPresent when the field is invalid (when wrapped in Field.Root).
data-touchedPresent when the field has been touched (when wrapped in Field.Root).
data-dirtyPresent when the field's value has changed (when wrapped in Field.Root).
data-filledPresent when the combobox has a value (when wrapped in Field.Root).
data-focusedPresent when the input is focused (when wrapped in Field.Root).
data-list-emptyPresent when no items are rendered.

Trigger

A button that opens the popup. Renders a <button> element.

PropTypeDefault
AttributeDescription
data-popup-openPresent when the popup is open.
data-pressedPresent when the trigger is pressed.
data-disabledPresent when the combobox is disabled.
data-popup-sideIndicates which side the popup is positioned relative to its anchor.
data-validPresent when the field is valid (when wrapped in Field.Root).
data-invalidPresent when the field is invalid (when wrapped in Field.Root).
data-touchedPresent when the field has been touched (when wrapped in Field.Root).
data-dirtyPresent when the field's value has changed (when wrapped in Field.Root).
data-filledPresent when the combobox has a value (when wrapped in Field.Root).
data-focusedPresent when the trigger is focused (when wrapped in Field.Root).
data-list-emptyPresent when no items are rendered.
data-placeholderPresent when the combobox has no value.

Clear

Clears the value when clicked. Renders a <button> element.

PropTypeDefault
AttributeDescription
data-popup-openPresent when the popup is open.
data-disabledPresent when the button is disabled.
data-visiblePresent when the clear button is visible.
data-starting-stylePresent when the clear button is animating in.
data-ending-stylePresent when the clear button is animating out.

Icon

An icon that indicates that the trigger button opens the popup. Renders a <span> element.

PropTypeDefault

Chips

A container for the chips in a multiselectable input. Renders a <div> element.

PropTypeDefault

Chip

An individual chip representing a selected value. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-disabledPresent when the combobox is disabled.

ChipRemove

A button to remove a chip. Renders a <button> element.

PropTypeDefault
AttributeDescription
data-disabledPresent when the chip remove button is disabled.

Value

The current value of the combobox. Doesn't render its own HTML element.

PropTypeDefault

Backdrop

An overlay displayed beneath the popup. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-starting-stylePresent when the backdrop is animating in.
data-ending-stylePresent when the backdrop is animating out.

Portal

A portal that moves the popup out to <body>, clear of ancestor clipping and stacking. Renders a <div> element.

PropTypeDefault

Positioner

Positions the popup against the trigger. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-sideWhich side of the anchor the popup is on.
data-alignHow the popup is aligned relative to the side.
data-anchor-hiddenPresent when the anchor is hidden.
data-emptyPresent when no items are rendered.
CSS VariableDescription
--available-widthAvailable width between the anchor and the viewport edge.
--available-heightAvailable height between the anchor and the viewport edge.
--anchor-widthWidth of the anchor element.
--anchor-heightHeight of the anchor element.
--transform-originTransform origin for scale animations.

Popup

A container for the list. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-starting-stylePresent when the popup is animating in.
data-ending-stylePresent when the popup is animating out.
data-sideWhich side of the anchor the popup is on.
data-alignHow the popup is aligned relative to the side.
data-emptyPresent when no items are rendered.
data-anchor-hiddenPresent when the anchor is hidden.

List

A list container for the items. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-emptyPresent when no items are rendered.

Collection

Renders filtered list items. Doesn't render its own HTML element. Grouped items need a nested pass: an outer <Combobox.Collection> over the groups, and another one inside each <Combobox.Group> for its items.

The children snippet receives (item, index) for each item to render.

Item

An individual item in the list. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-selectedPresent when this item is the selected value.
data-highlightedPresent when the item is highlighted.
data-disabledPresent when the item is disabled.

Row

Displays a single row of items in a grid list. Enable grid on the root component to turn the listbox into a grid. Renders a <div> element.

PropTypeDefault

ItemIndicator

Indicates whether the item is selected. Renders a <span> element.

PropTypeDefault
AttributeDescription
data-selectedPresent when the item is selected.
data-starting-stylePresent when the indicator is animating in.
data-ending-stylePresent when the indicator is animating out.

Empty

Renders its children only when the list is empty — with or without the items prop. Announces changes politely to screen readers. This component's root element must remain mounted in the DOM to announce changes consistently across screen readers. Avoid hiding or removing the component itself with display: none, hidden, aria-hidden, or conditional rendering. Prefer updating or conditionally rendering its children instead. Renders a <div> element.

PropTypeDefault

Status

Displays a status message whose content changes are announced politely to screen readers. Useful for conveying the status of an asynchronously loaded list. This component's root element must remain mounted in the DOM to announce changes consistently across screen readers. Avoid hiding or removing the component itself with display: none, hidden, aria-hidden, or conditional rendering. Prefer updating or conditionally rendering its children instead. Renders a <div> element.

PropTypeDefault

Arrow

Displays an element positioned against the anchor. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-openPresent when the popup is open.
data-closedPresent when the popup is closed.
data-sideWhich side of the anchor the popup is on.
data-alignHow the popup is aligned relative to the side.
data-uncenteredPresent when the arrow cannot be centered.

Group

Groups related items with the corresponding label. Renders a <div> element.

PropTypeDefault

GroupLabel

An accessible label that is automatically associated with its parent group. Renders a <div> element.

PropTypeDefault

Separator

A visual divider between groups of items. Rendered as role="presentation", because role="separator" is not valid inside a listbox. Renders a <div> element.

PropTypeDefault
AttributeDescription
data-orientationIndicates the orientation of the separator.

createFilter

A locale-aware filter helper. Returns three predicates (contains / startsWith / endsWith) built around Intl.Collator, so case- and accent-insensitive matching follows the user's locale. Each predicate also fits the filter prop's signature, so you can hand one to <Combobox.Root> as the internal filter.

It takes Intl.CollatorOptions plus locale, and optionally multiple and value — pass the current selection as value in single-select mode so the selected item keeps matching its own label.

<script>
  import { Combobox } from '@shardsui/svelte/combobox'

  const filter = Combobox.createFilter({ sensitivity: 'base' })
  const filtered = $derived(items.filter((it) => filter.contains(it.label, query)))
</script>

Additional types

HighlightReason

The reason passed to onItemHighlighted, reporting what moved the highlight.

type HighlightReason = 'keyboard' | 'pointer' | 'none'