Beta, expect API change, and bugs. Hit one? Tell us.

Skip to content

Combobox

An input with a filterable list.

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

type Tag = {
  label: string
  value: string
}

const id = useId()

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>

<template>
  <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="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"
          >
            <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>
          </Combobox.Clear>
          <Combobox.Trigger
            class="flex h-8 w-6 items-center justify-center rounded bg-transparent p-0"
            aria-label="Open popup"
          >
            <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>
          </Combobox.Trigger>
        </div>
      </Combobox.InputGroup>
    </div>

    <Combobox.Portal>
      <Combobox.Positioner class="outline-hidden" :side-offset="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 v-slot="{ item }">
              <Combobox.Item
                :value="item"
                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">
                  <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>
                </Combobox.ItemIndicator>
                <span class="col-start-2">{{ (item as Tag).label }}</span>
              </Combobox.Item>
            </Combobox.Collection>
          </Combobox.List>
        </Combobox.Popup>
      </Combobox.Positioner>
    </Combobox.Portal>
  </Combobox.Root>
</template>

Anatomy

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

<template>
  <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>
</template>

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 template-ref patterns.

Filtering

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

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

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

const value = shallowRef(null)
</script>

<template>
  <Combobox.Root :items="courses" v-model:value="value">
    <Combobox.InputGroup>
      <Combobox.Input />
    </Combobox.InputGroup>

    <Combobox.Portal>
      <Combobox.Positioner>
        <Combobox.Popup>
          <Combobox.List>
            <Combobox.Collection v-slot="{ item }">
              <Combobox.Item :value="item">
                {{ item.label }}
              </Combobox.Item>
            </Combobox.Collection>
          </Combobox.List>
        </Combobox.Popup>
      </Combobox.Positioner>
    </Combobox.Portal>
  </Combobox.Root>
</template>

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 computed and render with v-for.

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 setup lang="ts">
import { Combobox } from '@shardsui/vue/combobox'
import { shallowRef, useId } from 'vue'

type Topic = {
  id: string
  value: string
}

const id = useId()

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' }
]

const value = shallowRef<Topic[]>([])
</script>

<template>
  <Combobox.Root
    multiple
    :items="topics"
    v-model:value="value"
    :is-item-equal-to-value="(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">
          <Combobox.Chip
            v-for="topic in value"
            :key="topic.id"
            :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}`"
            >
              <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>
            </Combobox.ChipRemove>
          </Combobox.Chip>
          <Combobox.Input
            :id="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" :side-offset="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 v-slot="{ item }">
              <Combobox.Item
                :value="item"
                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">
                  <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>
                </Combobox.ItemIndicator>
                <span class="col-start-2">{{ (item as Topic).value }}</span>
              </Combobox.Item>
            </Combobox.Collection>
          </Combobox.List>
        </Combobox.Popup>
      </Combobox.Positioner>
    </Combobox.Portal>
  </Combobox.Root>
</template>

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

<script setup>
import { computed } from 'vue'

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

<template>
  <Combobox.Chips>
    <Combobox.Chip v-for="item in visibleValue" :key="item">
      {{ item }}
      <Combobox.ChipRemove :aria-label="`Remove ${item}`" />
    </Combobox.Chip>
    <span v-if="hiddenCount > 0">
      {{ `+${hiddenCount} more` }}
    </span>
    <Combobox.Input />
  </Combobox.Chips>
</template>

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 setup 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 setup lang="ts">
import { Combobox } from '@shardsui/vue/combobox'
import { useId } from 'vue'

type Topic = {
  id: string
  label: string
}

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

const id = useId()

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>

<template>
  <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="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"
          >
            <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>
          </Combobox.Clear>
          <Combobox.Trigger
            class="flex h-8 w-6 items-center justify-center rounded bg-transparent p-0"
            aria-label="Open popup"
          >
            <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>
          </Combobox.Trigger>
        </div>
      </Combobox.InputGroup>
    </div>

    <Combobox.Portal>
      <Combobox.Positioner class="outline-hidden" :side-offset="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 v-slot="{ item }">
              <Combobox.Group :items="(item as TopicGroup).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"
                >
                  {{ (item as TopicGroup).value }}
                </Combobox.GroupLabel>
                <Combobox.Collection v-slot="{ item: groupItem }">
                  <Combobox.Item
                    :value="groupItem"
                    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">
                      <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>
                    </Combobox.ItemIndicator>
                    <span class="col-start-2">{{ (groupItem as Topic).label }}</span>
                  </Combobox.Item>
                </Combobox.Collection>
              </Combobox.Group>
            </Combobox.Collection>
          </Combobox.List>
        </Combobox.Popup>
      </Combobox.Positioner>
    </Combobox.Portal>
  </Combobox.Root>
</template>

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 setup lang="ts">
import { Combobox } from '@shardsui/vue/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>

<template>
  <div class="flex flex-col gap-1">
    <Combobox.Root
      :items="people"
      :is-item-equal-to-value="(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 v-slot="{ value }">
          <template v-if="value">{{ (value as Person).label }}</template>
          <span v-else class="opacity-60">Select assignee</span>
        </Combobox.Value>
        <Combobox.Icon class="flex">
          <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>
        </Combobox.Icon>
      </Combobox.Trigger>
      <Combobox.Portal>
        <Combobox.Positioner class="z-10 outline-hidden" :side-offset="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 v-slot="{ item }">
                <Combobox.Item
                  :value="item"
                  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">
                    <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>
                  </Combobox.ItemIndicator>
                  <span class="col-start-2">{{ (item as Person).label }}</span>
                </Combobox.Item>
              </Combobox.Collection>
            </Combobox.List>
          </Combobox.Popup>
        </Combobox.Positioner>
      </Combobox.Portal>
    </Combobox.Root>
  </div>
</template>

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:

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

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 setup lang="ts">
import { Combobox } from '@shardsui/vue/combobox'
import { computed, shallowRef, useId } from 'vue'

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

const { contains } = Combobox.createFilter()

const id = useId()

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' }
]

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

const trimmed = computed(() => query.value.trim())

const items = computed(() => {
  const current = selected.value
  if (!current || results.value.some((product) => product.id === current.id)) return results.value
  return [...results.value, 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 && value === selected.value.name) {
    pending.value = false
    return
  }

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

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

function onOpenChangeComplete(open: boolean) {
  if (!open && selected.value) results.value = [selected.value]
}
</script>

<template>
  <Combobox.Root
    :items="items"
    v-model:value="selected"
    v-model:input-value="query"
    :item-to-string-label="(p: Product) => p.name"
    :is-item-equal-to-value="(a: Product, b: Product) => a.id === b.id"
    :filter="null"
    @update:input-value="searchCatalog"
    @open-change-complete="onOpenChangeComplete"
  >
    <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="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"
          >
            <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>
          </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"
          >
            <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>
          </Combobox.Trigger>
        </div>
      </Combobox.InputGroup>
    </div>

    <Combobox.Portal>
      <Combobox.Positioner class="outline-hidden" :side-offset="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>
            <div
              v-if="pending"
              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>
            <div
              v-else-if="trimmed === '' && !selected"
              class="flex items-center gap-2 py-1 pr-5 pl-2 text-sm text-gray-600"
            >
              Start typing to search…
            </div>
          </Combobox.Status>
          <Combobox.Empty>
            <div v-if="trimmed !== '' && !pending" class="py-2 pr-4 pl-2 text-sm/4 text-gray-600">
              No results found.
            </div>
          </Combobox.Empty>
          <Combobox.List>
            <Combobox.Collection v-slot="{ item }">
              <Combobox.Item
                :value="item"
                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">
                  <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>
                </Combobox.ItemIndicator>
                <span class="col-start-2 flex flex-col gap-0.5">
                  <span class="text-sm font-semibold">{{ (item as Product).name }}</span>
                  <span class="text-xs text-gray-600">{{ (item as Product).description }}</span>
                </span>
              </Combobox.Item>
            </Combobox.Collection>
          </Combobox.List>
        </Combobox.Popup>
      </Combobox.Positioner>
    </Combobox.Portal>
  </Combobox.Root>
</template>

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 setup lang="ts">
import { Combobox } from '@shardsui/vue/combobox'
import { computed, shallowRef, useId } from 'vue'

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

const { contains } = Combobox.createFilter()

const id = useId()

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' }
]

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

const trimmed = computed(() => query.value.trim())

const items = computed(() => {
  if (value.value.length === 0) return results.value
  const merged = [...results.value]
  for (const person of value.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.value = []
    pending.value = false
    return
  }

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

function onValueChange(next: Person[] | null | undefined) {
  query.value = ''
  if (!next?.length) results.value = []
}

function onOpenChangeComplete(open: boolean) {
  if (!open) results.value = []
}
</script>

<template>
  <Combobox.Root
    multiple
    :items="items"
    v-model:value="value"
    v-model:input-value="query"
    :item-to-string-label="(p: Person) => p.name"
    :is-item-equal-to-value="(a: Person, b: Person) => a.id === b.id"
    :filter="null"
    @update:input-value="searchDirectory"
    @update:value="onValueChange"
    @open-change-complete="onOpenChangeComplete"
  >
    <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">
          <Combobox.Chip
            v-for="person in value"
            :key="person.id"
            :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}`"
            >
              <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>
            </Combobox.ChipRemove>
          </Combobox.Chip>
          <Combobox.Input
            :id="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" :side-offset="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>
            <div
              v-if="pending"
              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>
            <div
              v-else-if="trimmed === '' && value.length === 0"
              class="flex items-center gap-2 py-1 pr-5 pl-2 text-sm text-gray-600"
            >
              Start typing to search people…
            </div>
          </Combobox.Status>
          <Combobox.Empty>
            <div v-if="trimmed !== '' && !pending" class="py-2 pr-4 pl-2 text-sm/4 text-gray-600">
              No people found.
            </div>
          </Combobox.Empty>
          <Combobox.List>
            <Combobox.Collection v-slot="{ item }">
              <Combobox.Item
                :value="item"
                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">
                  <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>
                </Combobox.ItemIndicator>
                <span class="col-start-2 flex flex-col gap-0.5">
                  <span class="text-sm font-semibold">{{ (item as Person).name }}</span>
                  <span class="text-xs text-gray-600">{{ (item as Person).role }}</span>
                </span>
              </Combobox.Item>
            </Combobox.Collection>
          </Combobox.List>
        </Combobox.Popup>
      </Combobox.Positioner>
    </Combobox.Portal>
  </Combobox.Root>
</template>

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 setup lang="ts">
import { Combobox } from '@shardsui/vue/combobox'
import { Dialog } from '@shardsui/vue/dialog'
import { computed, shallowRef, useId, useTemplateRef } from 'vue'

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

const id = useId()

const tags = shallowRef<Tag[]>([
  { id: 'kerning', value: 'kerning' },
  { id: 'tracking', value: 'tracking' },
  { id: 'leading', value: 'leading' }
])
const selected = shallowRef<Tag[]>([])
const query = shallowRef('')
const openDialog = shallowRef(false)
const draftName = shallowRef('')
const createInput = useTemplateRef<HTMLInputElement>('createInput')
const highlighted = shallowRef<Tag | undefined>()

const trimmed = computed(() => query.value.trim())
const match = computed(() => findTag(trimmed.value))

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

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

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

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

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

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

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

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

  draftName.value = trimmed.value
  openDialog.value = true
}

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

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

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

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

<template>
  <Combobox.Root
    :items="items"
    :value="selected"
    @update:value="commitSelection"
    v-model:input-value="query"
    multiple
    :is-item-equal-to-value="(a: Tag, b: Tag) => a.id === b.id"
    @item-highlighted="(item?: Tag) => (highlighted = item)"
  >
    <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">
          <Combobox.Chip
            v-for="tag in selected"
            :key="tag.id"
            :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}`"
            >
              <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>
            </Combobox.ChipRemove>
          </Combobox.Chip>
          <Combobox.Input
            :id="id"
            :on-keydown="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" :side-offset="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 v-slot="{ item }">
              <Combobox.Item
                :value="item"
                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"
              >
                <template v-if="(item as Tag).creatable">
                  <span class="col-start-1">
                    <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>
                  </span>
                  <span class="col-start-2">Create "{{ (item as Tag).creatable }}"</span>
                </template>
                <template v-else>
                  <Combobox.ItemIndicator class="col-start-1">
                    <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>
                  </Combobox.ItemIndicator>
                  <span class="col-start-2">{{ (item as Tag).value }}</span>
                </template>
              </Combobox.Item>
            </Combobox.Collection>
          </Combobox.List>
        </Combobox.Popup>
      </Combobox.Positioner>
    </Combobox.Portal>
  </Combobox.Root>

  <Dialog.Root v-model: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
        :initial-focus="() => createInput"
        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 @submit="createTag">
          <input
            ref="createInput"
            v-model="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>
</template>

Virtualized

For large datasets, renders only the visible items.

<script setup lang="ts">
import { Combobox } from '@shardsui/vue/combobox'
import { computed, shallowRef, useId, useTemplateRef } from 'vue'

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 = useId()

const inputValue = shallowRef('')

const scrollEl = useTemplateRef<HTMLElement>('scrollEl')
const scrollTop = shallowRef(0)

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

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

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

function onScroll(event: Event) {
  if (event.target instanceof HTMLElement) scrollTop.value = event.target.scrollTop
}
</script>

<template>
  <Combobox.Root
    virtualized
    v-model:input-value="inputValue"
    :filtered-items="filteredItems"
    :filter="null"
    :is-item-equal-to-value="(a: Item, b: Item) => a.id === b.id"
    :item-to-string-label="(item: Item) => (item ? item.name : '')"
    @item-highlighted="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="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"
          >
            <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>
          </Combobox.Clear>
          <Combobox.Trigger
            class="flex h-8 w-6 items-center justify-center rounded bg-transparent p-0"
            aria-label="Open popup"
          >
            <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>
          </Combobox.Trigger>
        </div>
      </Combobox.InputGroup>
    </div>

    <Combobox.Portal>
      <Combobox.Positioner class="outline-hidden" :side-offset="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
              ref="scrollEl"
              role="presentation"
              class="h-[min(22.5rem,var(--total-size))] max-h-(--available-height) scroll-py-1 overflow-auto overscroll-contain"
              :style="{ '--total-size': `${totalHeight}px` }"
              @scroll="onScroll"
            >
              <div
                role="presentation"
                class="relative w-full"
                :style="{ height: `${totalHeight}px` }"
              >
                <div
                  role="presentation"
                  class="absolute inset-x-0"
                  :style="{ top: `${offsetTop}px` }"
                >
                  <Combobox.Item
                    v-for="(item, i) in slice"
                    :key="item.id"
                    :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">
                      <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>
                    </Combobox.ItemIndicator>
                    <span class="col-start-2">{{ item.name }}</span>
                  </Combobox.Item>
                </div>
              </div>
            </div>
          </Combobox.List>
        </Combobox.Popup>
      </Combobox.Positioner>
    </Combobox.Portal>
  </Combobox.Root>
</template>

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 indicating that the trigger 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 default slot receives { item, index }.

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. Its root element must stay mounted for announcements to work consistently across screen readers: don't hide or remove it with display: none, hidden, aria-hidden, or conditional rendering — update or conditionally render 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. Its root element must stay mounted for announcements to work consistently across screen readers: don't hide or remove it with display: none, hidden, aria-hidden, or conditional rendering — update or conditionally render 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 setup>
import { computed } from 'vue'
import { Combobox } from '@shardsui/vue/combobox'

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

Additional types

HighlightReason

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

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