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

Skip to content

Autocomplete

An input with type-ahead suggestions.

<script setup lang="ts">
import { Autocomplete } from '@shardsui/vue/autocomplete'

const tags = [
  'Kerning & Tracking',
  'Type Scale',
  'Contrast Ratio',
  'OKLCH Color',
  'Flexbox & Grid',
  'Easing & Motion',
  'Semantic HTML',
  'Design Tokens'
]
</script>

<template>
  <Autocomplete.Root :items="tags">
    <label class="flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
      Search tags
      <Autocomplete.Input
        placeholder="e.g. Kerning"
        class="h-8 w-64 rounded-md border border-gray-200 bg-gray-50 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"
      />
    </label>

    <Autocomplete.Portal>
      <Autocomplete.Positioner class="outline-hidden" :side-offset="4">
        <Autocomplete.Popup
          class="max-h-92 w-(--anchor-width) max-w-(--available-width) rounded-md bg-gray-50 text-gray-900 shadow-lg outline-1 outline-gray-200"
        >
          <Autocomplete.Empty>
            <div class="py-4 pr-4 pl-2 text-sm/4 text-gray-600">No results found.</div>
          </Autocomplete.Empty>
          <Autocomplete.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"
          >
            <Autocomplete.Collection v-slot="{ item }">
              <Autocomplete.Item
                :value="item"
                class="flex 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"
              >
                {{ item }}
              </Autocomplete.Item>
            </Autocomplete.Collection>
          </Autocomplete.List>
        </Autocomplete.Popup>
      </Autocomplete.Positioner>
    </Autocomplete.Portal>
  </Autocomplete.Root>
</template>

Anatomy

<script setup>
import { Autocomplete } from '@shardsui/vue/autocomplete'
</script>

<template>
  <Autocomplete.Root>
    <Autocomplete.InputGroup>
      <Autocomplete.Input />
      <Autocomplete.Trigger />
      <Autocomplete.Icon />
      <Autocomplete.Clear />
      <Autocomplete.Value />
    </Autocomplete.InputGroup>

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

Usage guidelines

  • Autocomplete vs Combobox: use Autocomplete for free-form text input with suggestions. Use Combobox when the input is restricted to a predefined set of items.
  • The value is a string: unlike Combobox, the autocomplete's value is the input string itself.
  • Pass items for built-in filtering: the autocomplete filters as the user types; render matches with <Autocomplete.Collection> inside <Autocomplete.List>. See Filtering for async or custom filtering.
  • Give the input an accessible name: associate a native <label> with <Autocomplete.Input>, or wrap the autocomplete in the Field parts and label it there. See the forms guide.

TypeScript

<Autocomplete.Root> is generic over its item type, but nothing infers it: items is typed NoInfer<Value>[], so the type has to come from a typed wrapper. <Autocomplete.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 <Autocomplete.Collection>:

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

const tags = ['Vue', 'TypeScript', 'CSS', 'Accessibility']

const value = shallowRef('')
</script>

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

    <Autocomplete.Portal>
      <Autocomplete.Positioner>
        <Autocomplete.Popup>
          <Autocomplete.List>
            <Autocomplete.Collection v-slot="{ item }">
              <Autocomplete.Item :value="item">{{ item }}</Autocomplete.Item>
            </Autocomplete.Collection>
          </Autocomplete.List>
        </Autocomplete.Popup>
      </Autocomplete.Positioner>
    </Autocomplete.Portal>
  </Autocomplete.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

When suggestions come from a server, fetch them as the user types and surface loading or error text through custom status content.

<script setup lang="ts">
import { Autocomplete } from '@shardsui/vue/autocomplete'
import { computed, shallowRef } from 'vue'

type Page = {
  title: string
  section: string
}

const pages: Page[] = [
  { title: 'Kerning and tracking', section: 'Typography' },
  { title: 'Building a color ramp', section: 'Color' },
  { title: 'Grid vs. flexbox', section: 'Layout' },
  { title: 'Focus states and keyboard nav', section: 'Accessibility' },
  { title: 'Naming design tokens', section: 'Tokens' },
  { title: 'Easing and duration', section: 'Motion' },
  { title: 'Fluid typography', section: 'Responsive' },
  { title: 'Color scales', section: 'Data viz' },
  { title: 'ARIA in practice', section: 'Accessibility' },
  { title: 'Skeleton loading states', section: 'Performance' }
]

const filter = Autocomplete.createFilter()

async function searchPages(query: string): Promise<{ pages: Page[]; error: string | null }> {
  await new Promise((resolve) => setTimeout(resolve, Math.random() * 400 + 200))
  if (query === 'error') {
    return { pages: [], error: 'Could not reach the server. Please try again.' }
  }
  return {
    pages: pages.filter(
      (page) => filter.contains(page.title, query) || filter.contains(page.section, query)
    ),
    error: null
  }
}

const value = shallowRef('')
const results = shallowRef<Page[]>([])
const error = shallowRef<string | null>(null)
const pending = shallowRef(false)

let requestId = 0

async function search(query: string) {
  const id = ++requestId

  if (!query) {
    results.value = []
    error.value = null
    pending.value = false
    return
  }

  pending.value = true
  error.value = null

  const result = await searchPages(query)
  if (id !== requestId) return

  results.value = result.pages
  error.value = result.error
  pending.value = false
}

const status = computed(() => {
  if (error.value) return error.value
  if (!value.value) return null
  if (results.value.length === 0) return `No results match "${value.value}".`
  return `${results.value.length} ${results.value.length === 1 ? 'result' : 'results'} found`
})
</script>

<template>
  <Autocomplete.Root
    :items="results"
    :filter="null"
    :item-to-string-value="(page: Page) => page.title"
    v-model:value="value"
    @update:value="search"
  >
    <label class="flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
      Search pages
      <Autocomplete.Input
        placeholder="e.g. Kerning"
        class="h-8 w-64 rounded-md border border-gray-200 bg-gray-50 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"
      />
    </label>

    <Autocomplete.Portal>
      <Autocomplete.Positioner class="outline-hidden" :side-offset="4" align="start">
        <Autocomplete.Popup
          :aria-busy="pending || undefined"
          class="max-h-[min(var(--available-height),22.5rem)] w-(--anchor-width) max-w-(--available-width) 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"
        >
          <Autocomplete.Status>
            <div
              v-if="pending"
              class="flex items-center gap-2 py-1 pr-8 pl-2 text-sm text-gray-600"
            >
              <div
                class="size-3 animate-spin rounded-full border-2 border-gray-200 border-t-gray-600"
                aria-hidden="true"
              ></div>
              Searching…
            </div>
            <div v-else-if="status" class="py-1 pr-8 pl-2 text-sm text-gray-600">
              {{ status }}
            </div>
          </Autocomplete.Status>
          <Autocomplete.List>
            <Autocomplete.Collection v-slot="{ item }">
              <Autocomplete.Item
                :value="item"
                class="flex 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"
              >
                <span class="flex w-full flex-col gap-1">
                  <span class="leading-5 font-semibold">{{ (item as Page).title }}</span>
                  <span class="text-sm/4 opacity-80">{{ (item as Page).section }}</span>
                </span>
              </Autocomplete.Item>
            </Autocomplete.Collection>
          </Autocomplete.List>
        </Autocomplete.Popup>
      </Autocomplete.Positioner>
    </Autocomplete.Portal>
  </Autocomplete.Root>
</template>

Inline autocomplete

Set mode to both or inline to have the input fill itself in with the highlighted item as you arrow through the list.

<script setup lang="ts">
import { Autocomplete } from '@shardsui/vue/autocomplete'

const topics = [
  'Kerning',
  'Contrast ratio',
  'Flexbox',
  'Focus state',
  'Easing',
  'Design tokens',
  'Grid',
  'Semantic HTML'
]
</script>

<template>
  <Autocomplete.Root :items="topics" mode="both">
    <label class="flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
      Pick a topic to learn
      <Autocomplete.Input
        placeholder="e.g. Flexbox"
        class="h-8 w-64 rounded-md border border-gray-200 bg-gray-50 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"
      />
    </label>

    <Autocomplete.Portal>
      <Autocomplete.Positioner class="outline-hidden data-empty:hidden" :side-offset="4">
        <Autocomplete.Popup
          class="max-h-92 w-(--anchor-width) max-w-(--available-width) rounded-md bg-gray-50 text-gray-900 shadow-lg outline-1 outline-gray-200"
        >
          <Autocomplete.List
            class="max-h-[min(22.5rem,var(--available-height))] scroll-py-1 overflow-y-auto overscroll-contain py-1 outline-0"
          >
            <Autocomplete.Collection v-slot="{ item }">
              <Autocomplete.Item
                :value="item"
                class="flex 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"
              >
                {{ item }}
              </Autocomplete.Item>
            </Autocomplete.Collection>
          </Autocomplete.List>
        </Autocomplete.Popup>
      </Autocomplete.Positioner>
    </Autocomplete.Portal>
  </Autocomplete.Root>
</template>

Grouped

Sort related suggestions into labeled sections with <Autocomplete.Group> and <Autocomplete.GroupLabel>.

Model grouped data as an array of group objects, each carrying its own items array plus an extra field, such as value, that you read when rendering the heading.

<script setup lang="ts">
import { Autocomplete } from '@shardsui/vue/autocomplete'

type Subject = {
  value: string
  items: string[]
}

const subjects: Subject[] = [
  { value: 'Design', items: ['Kerning', 'Type scale', 'Negative space'] },
  { value: 'Frontend', items: ['HTML', 'CSS', 'JavaScript'] },
  { value: 'Accessibility', items: ['aria-label', 'Focus state', 'Contrast ratio'] }
]
</script>

<template>
  <Autocomplete.Root :items="subjects">
    <label class="flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
      Find a topic
      <Autocomplete.Input
        placeholder="e.g. CSS"
        class="h-8 w-64 rounded-md border border-gray-200 bg-gray-50 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"
      />
    </label>

    <Autocomplete.Portal>
      <Autocomplete.Positioner class="outline-hidden" :side-offset="4">
        <Autocomplete.Popup
          class="max-h-90 w-(--anchor-width) max-w-(--available-width) rounded-md bg-gray-50 text-gray-900 shadow-lg outline-1 outline-gray-200"
        >
          <Autocomplete.Empty>
            <div class="py-4 pr-4 pl-2 text-sm/4 text-gray-600">No topics found.</div>
          </Autocomplete.Empty>
          <Autocomplete.List
            class="max-h-[min(22.5rem,var(--available-height))] scroll-pt-9 scroll-pb-1 overflow-y-auto overscroll-contain outline-0"
          >
            <Autocomplete.Collection v-slot="{ item }">
              <Autocomplete.Group :items="(item as Subject).items" class="block pb-2">
                <Autocomplete.GroupLabel
                  class="sticky top-0 z-1 mr-2 w-[calc(100%-0.5rem)] bg-gray-50 px-2 pt-2 pb-1 text-xs font-semibold tracking-wider uppercase"
                >
                  {{ (item as Subject).value }}
                </Autocomplete.GroupLabel>
                <Autocomplete.Collection v-slot="{ item: topic }">
                  <Autocomplete.Item
                    :value="topic"
                    class="flex 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"
                  >
                    {{ topic }}
                  </Autocomplete.Item>
                </Autocomplete.Collection>
              </Autocomplete.Group>
            </Autocomplete.Collection>
          </Autocomplete.List>
        </Autocomplete.Popup>
      </Autocomplete.Positioner>
    </Autocomplete.Portal>
  </Autocomplete.Root>
</template>

Fuzzy matching

Any matching strategy works: filter externally and pass the result via items or filteredItems (see Filtering).

<script setup lang="ts">
import { Autocomplete } from '@shardsui/vue/autocomplete'

type Item = {
  title: string
  summary: string
}

const items: Item[] = [
  { title: 'Grid systems in layout', summary: 'Structure pages with columns and gutters' },
  { title: 'Choosing a type scale', summary: 'Set consistent heading and body sizes' },
  { title: 'Color contrast basics', summary: 'Hit the WCAG contrast ratio' },
  { title: 'Pairing typefaces', summary: 'Match x-height and cap height' },
  { title: 'Spacing and rhythm', summary: 'Use negative space to guide the eye' },
  { title: 'Designing with constraints', summary: 'Turn limits into creative direction' }
]

function fuzzyMatch(text: string, query: string): boolean {
  const haystack = text.toLowerCase()
  const needle = query.toLowerCase()
  let i = 0
  for (let j = 0; j < haystack.length && i < needle.length; j += 1) {
    if (haystack[j] === needle[i]) i += 1
  }
  return i === needle.length
}

function fuzzyFilter(item: Item, query: string): boolean {
  const needle = query.trim()
  return fuzzyMatch(item.title, needle) || fuzzyMatch(item.summary, needle)
}
</script>

<template>
  <Autocomplete.Root
    :items="items"
    :filter="fuzzyFilter"
    :item-to-string-value="(item: Item) => item.title"
  >
    <label class="flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
      Search items
      <Autocomplete.Input
        placeholder="e.g. grdsys"
        class="h-8 w-64 rounded-md border border-gray-200 bg-gray-50 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"
      />
    </label>

    <Autocomplete.Portal>
      <Autocomplete.Positioner class="outline-hidden" :side-offset="4">
        <Autocomplete.Popup
          class="max-h-[min(var(--available-height),28rem)] w-(--anchor-width) max-w-(--available-width) scroll-py-2 overflow-y-auto overscroll-contain rounded-md bg-gray-50 py-1 text-gray-900 shadow-lg outline-1 outline-gray-200"
        >
          <Autocomplete.Empty>
            <div class="py-3 pr-4 pl-2 text-sm/4 text-gray-600">
              No results found for "<Autocomplete.Value />"
            </div>
          </Autocomplete.Empty>
          <Autocomplete.List class="flex flex-col">
            <Autocomplete.Collection v-slot="{ item }">
              <Autocomplete.Item
                :value="item"
                class="flex flex-col gap-1 py-3 pr-2 pl-2.5 text-sm/4 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 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-200"
              >
                <span class="leading-5 font-semibold">{{ (item as Item).title }}</span>
                <span class="text-sm/5 text-gray-600">{{ (item as Item).summary }}</span>
              </Autocomplete.Item>
            </Autocomplete.Collection>
          </Autocomplete.List>
        </Autocomplete.Popup>
      </Autocomplete.Positioner>
    </Autocomplete.Portal>
  </Autocomplete.Root>
</template>

Limit results

Cap how many suggestions render at once, and use <Autocomplete.Status> to report the matches the list is holding back.

<script setup lang="ts">
import { Autocomplete } from '@shardsui/vue/autocomplete'
import { computed, shallowRef } from 'vue'

const limit = 5

const tags = [
  'Accessible Components',
  'Animation Principles',
  'ARIA in Practice',
  'Color & Contrast',
  'Component API Design',
  'CSS Architecture',
  'Dark Mode',
  'Data Visualization',
  'Design Critique',
  'Design Handoff',
  'Design Systems Foundations',
  'Design Tokens',
  'Fluid Typography',
  'Forms & Validation',
  'Grid Systems',
  'Icon Design',
  'Intro to Typography',
  'Layout & Grids',
  'Motion & Animation',
  'Performance for Frontend',
  'Prototyping in Figma',
  'Responsive Design',
  'Semantic HTML',
  'State & Data Flow',
  'SVG & Vector',
  'Type Scales',
  'UX Writing',
  'Visual Hierarchy',
  'Web Animation',
  'WCAG Essentials'
]

const filter = Autocomplete.createFilter()

const value = shallowRef('')

const matchCount = computed(() => tags.filter((tag) => filter.contains(tag, value.value)).length)
const hiddenCount = computed(() => Math.max(0, matchCount.value - limit))
</script>

<template>
  <Autocomplete.Root :items="tags" v-model:value="value" :limit="limit">
    <label class="flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
      Search tags
      <Autocomplete.Input
        placeholder="e.g. design"
        class="h-8 w-64 rounded-md border border-gray-200 bg-gray-50 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"
      />
    </label>

    <Autocomplete.Portal>
      <Autocomplete.Positioner class="outline-hidden" :side-offset="4">
        <Autocomplete.Popup
          class="max-h-[min(var(--available-height),22.5rem)] w-(--anchor-width) max-w-(--available-width) 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"
        >
          <Autocomplete.Empty>
            <div class="py-2 pr-4 pl-2 text-sm/4 text-gray-600">
              No results found for "{{ value }}"
            </div>
          </Autocomplete.Empty>
          <Autocomplete.List>
            <Autocomplete.Collection v-slot="{ item }">
              <Autocomplete.Item
                :value="item"
                class="flex 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"
              >
                {{ item }}
              </Autocomplete.Item>
            </Autocomplete.Collection>
          </Autocomplete.List>
          <Autocomplete.Status>
            <div v-if="hiddenCount > 0" class="mt-1 py-2 pr-4 pl-2 text-sm/5 text-gray-600">
              {{ hiddenCount }} more hidden — keep typing to narrow the list.
            </div>
          </Autocomplete.Status>
        </Autocomplete.Popup>
      </Autocomplete.Positioner>
    </Autocomplete.Portal>
  </Autocomplete.Root>
</template>

Auto highlight

Set autoHighlight so the first match is highlighted as soon as the query matches something, ready to accept with a single Enter. Pass 'always' to keep a highlight even while the input is empty, such as when the list renders inline inside a dialog. keepHighlight and highlightItemOnHover control what the pointer does to the highlight.

<script setup lang="ts">
import { Autocomplete } from '@shardsui/vue/autocomplete'

const statuses = [
  'Not started',
  'In progress',
  'Needs review',
  'Completed',
  'Bookmarked',
  'Skipped'
]
</script>

<template>
  <Autocomplete.Root :items="statuses" auto-highlight>
    <label class="flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
      Set status
      <Autocomplete.Input
        placeholder="e.g. In progress"
        class="h-8 w-64 rounded-md border border-gray-200 bg-gray-50 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"
      />
    </label>

    <Autocomplete.Portal>
      <Autocomplete.Positioner class="outline-hidden" :side-offset="4">
        <Autocomplete.Popup
          class="max-h-92 w-(--anchor-width) max-w-(--available-width) rounded-md bg-gray-50 text-gray-900 shadow-lg outline-1 outline-gray-200"
        >
          <Autocomplete.Empty>
            <div class="py-4 pr-4 pl-2 text-sm/4 text-gray-600">No status found.</div>
          </Autocomplete.Empty>
          <Autocomplete.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"
          >
            <Autocomplete.Collection v-slot="{ item }">
              <Autocomplete.Item
                :value="item"
                class="flex 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"
              >
                {{ item }}
              </Autocomplete.Item>
            </Autocomplete.Collection>
          </Autocomplete.List>
        </Autocomplete.Popup>
      </Autocomplete.Positioner>
    </Autocomplete.Portal>
  </Autocomplete.Root>
</template>

Command palette

Turn the input into a command filter: typing narrows the list, and each item runs an action when clicked instead of selecting a value.

<script setup lang="ts">
import { Autocomplete } from '@shardsui/vue/autocomplete'
import { Dialog } from '@shardsui/vue/dialog'
import { ScrollArea } from '@shardsui/vue/scroll-area'
import { onMounted, onUnmounted, shallowRef } from 'vue'

type Group = {
  value: string
  kind: string
  items: string[]
}

const groups: Group[] = [
  {
    value: 'Pages',
    kind: 'Page',
    items: ['Kerning & Tracking', 'Contrast Ratio', 'Flexbox & Grid', 'Design Tokens']
  },
  {
    value: 'Actions',
    kind: 'Action',
    items: ['Open settings', 'View activity', 'Create file', 'View profile']
  }
]

const open = shallowRef(false)

function onKeydown(event: KeyboardEvent) {
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
    event.preventDefault()
    open.value = true
  }
}

onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>

<template>
  <Dialog.Root v-model:open="open">
    <Dialog.Trigger
      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"
    >
      Open command palette
    </Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Backdrop
        class="fixed inset-0 bg-black opacity-20 transition-opacity duration-150 ease-[cubic-bezier(0.45,1.005,0,1.005)] data-ending-style:opacity-0 data-starting-style:opacity-0 supports-[-webkit-touch-callout:none]:absolute"
      />
      <Dialog.Viewport
        class="fixed inset-0 flex items-start justify-center overflow-hidden px-2 pt-18 pb-2"
      >
        <Dialog.Popup
          aria-label="Command palette"
          class="relative flex max-h-[min(36rem,calc(100dvh-5rem))] w-[calc(100vw-1rem)] max-w-md flex-col overflow-hidden rounded-2xl bg-gray-50 text-gray-900 shadow-2xl outline-1 outline-black/4 transition-[opacity,transform,scale,translate] duration-150 data-ending-style:-translate-y-4 data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:-translate-y-4 data-starting-style:scale-95 data-starting-style:opacity-0"
        >
          <Autocomplete.Root open :items="groups" inline auto-highlight="always" keep-highlight>
            <Autocomplete.Input
              aria-label="Search commands"
              class="w-full border-0 border-b border-gray-100 bg-transparent p-4 text-sm font-normal tracking-wide text-gray-900 outline-hidden placeholder:text-gray-500 any-pointer-coarse:text-base"
              placeholder="Search pages and actions…"
            />
            <Dialog.Close class="sr-only">Close command palette</Dialog.Close>

            <ScrollArea.Root
              class="relative flex max-h-[min(60dvh,24rem)] min-h-0 flex-[0_1_auto] overflow-hidden"
            >
              <ScrollArea.Viewport
                class="min-h-0 flex-1 scroll-py-1 overscroll-contain focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-gray-950"
              >
                <ScrollArea.Content style="min-width: 100%">
                  <Autocomplete.Empty>
                    <div
                      class="flex min-h-32 items-center justify-center py-4 pr-4 pl-2 text-sm/4 text-gray-600"
                    >
                      No results found.
                    </div>
                  </Autocomplete.Empty>

                  <Autocomplete.List class="p-2">
                    <Autocomplete.Collection v-slot="{ item: group }">
                      <Autocomplete.Group
                        :items="(group as Group).items"
                        class="block not-last:mb-1"
                      >
                        <Autocomplete.GroupLabel
                          class="m-0 flex h-8 items-center px-3 text-sm leading-none font-normal tracking-normal text-gray-600 outline-hidden select-none"
                        >
                          {{ (group as Group).value }}
                        </Autocomplete.GroupLabel>
                        <Autocomplete.Collection v-slot="{ item: command }">
                          <Autocomplete.Item
                            :value="command"
                            :on-click="() => (open = false)"
                            class="group grid min-h-8 scroll-my-1 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-md pr-3 pl-9 text-sm/4.5 font-normal tracking-wide outline-hidden select-none data-highlighted:bg-gray-100"
                          >
                            <span class="truncate font-normal">{{ command }}</span>
                            <span
                              class="shrink-0 text-sm tracking-normal whitespace-nowrap text-gray-500 group-data-highlighted:text-gray-700"
                            >
                              {{ (group as Group).kind }}
                            </span>
                          </Autocomplete.Item>
                        </Autocomplete.Collection>
                      </Autocomplete.Group>
                    </Autocomplete.Collection>
                  </Autocomplete.List>
                </ScrollArea.Content>
              </ScrollArea.Viewport>
              <ScrollArea.Scrollbar class="-mr-1 flex w-6 justify-center py-2">
                <ScrollArea.Thumb
                  class="flex w-full justify-center before:block before:h-full before:w-1 before:rounded-sm before:bg-gray-400 before:content-['']"
                />
              </ScrollArea.Scrollbar>
            </ScrollArea.Root>

            <div
              class="flex items-center justify-between border-t border-gray-200 bg-gray-100 px-3 py-2.5 text-xs text-gray-600"
            >
              <div class="flex items-center gap-2">
                <span>Run</span>
                <kbd
                  class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-gray-300 bg-gray-100 px-1 text-xs font-normal text-gray-700"
                >
                  Enter
                </kbd>
              </div>
              <div class="flex items-center gap-2">
                <span>Open palette</span>
                <kbd
                  class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-gray-300 bg-gray-100 px-1 text-xs font-normal text-gray-700"
                >
                  Cmd
                </kbd>
                <kbd
                  class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-gray-300 bg-gray-100 px-1 text-xs font-normal text-gray-700"
                >
                  K
                </kbd>
              </div>
            </div>
          </Autocomplete.Root>
        </Dialog.Popup>
      </Dialog.Viewport>
    </Dialog.Portal>
  </Dialog.Root>
</template>

Grid layout

Compact items like icons or swatches read better in a grid. Set the grid prop and wrap each row in an <Autocomplete.Row>.

<script setup lang="ts">
import { Autocomplete } from '@shardsui/vue/autocomplete'
import { nextTick, shallowRef, useTemplateRef } from 'vue'

const COLUMNS = 5

type EmojiItem = {
  emoji: string
  name: string
}

type EmojiGroup = {
  label: string
  items: EmojiItem[]
}

function chunk<T>(array: T[], size: number): T[][] {
  const rows: T[][] = []
  for (let i = 0; i < array.length; i += size) rows.push(array.slice(i, i + size))
  return rows
}

const emojiGroups: EmojiGroup[] = [
  {
    label: 'Smileys & Emotion',
    items: [
      { emoji: '😀', name: 'grinning face' },
      { emoji: '😃', name: 'grinning face with big eyes' },
      { emoji: '😄', name: 'grinning face with smiling eyes' },
      { emoji: '😁', name: 'beaming face with smiling eyes' },
      { emoji: '😆', name: 'grinning squinting face' },
      { emoji: '😅', name: 'grinning face with sweat' },
      { emoji: '🤣', name: 'rolling on the floor laughing' },
      { emoji: '😂', name: 'face with tears of joy' },
      { emoji: '🙂', name: 'slightly smiling face' },
      { emoji: '🙃', name: 'upside-down face' },
      { emoji: '😉', name: 'winking face' },
      { emoji: '😊', name: 'smiling face with smiling eyes' },
      { emoji: '😇', name: 'smiling face with halo' },
      { emoji: '🥰', name: 'smiling face with hearts' },
      { emoji: '😍', name: 'smiling face with heart-eyes' },
      { emoji: '🤩', name: 'star-struck' },
      { emoji: '😘', name: 'face blowing a kiss' },
      { emoji: '😗', name: 'kissing face' },
      { emoji: '☺️', name: 'smiling face' },
      { emoji: '😚', name: 'kissing face with closed eyes' },
      { emoji: '😙', name: 'kissing face with smiling eyes' },
      { emoji: '🥲', name: 'smiling face with tear' },
      { emoji: '😋', name: 'face savoring food' },
      { emoji: '😛', name: 'face with tongue' },
      { emoji: '😜', name: 'winking face with tongue' },
      { emoji: '🤪', name: 'zany face' },
      { emoji: '😝', name: 'squinting face with tongue' },
      { emoji: '🤑', name: 'money-mouth face' },
      { emoji: '🤗', name: 'hugging face' },
      { emoji: '🤭', name: 'face with hand over mouth' }
    ]
  },
  {
    label: 'Animals & Nature',
    items: [
      { emoji: '🐶', name: 'dog face' },
      { emoji: '🐱', name: 'cat face' },
      { emoji: '🐭', name: 'mouse face' },
      { emoji: '🐹', name: 'hamster' },
      { emoji: '🐰', name: 'rabbit face' },
      { emoji: '🦊', name: 'fox' },
      { emoji: '🐻', name: 'bear' },
      { emoji: '🐼', name: 'panda' },
      { emoji: '🐨', name: 'koala' },
      { emoji: '🐯', name: 'tiger face' },
      { emoji: '🦁', name: 'lion' },
      { emoji: '🐮', name: 'cow face' },
      { emoji: '🐷', name: 'pig face' },
      { emoji: '🐽', name: 'pig nose' },
      { emoji: '🐸', name: 'frog' },
      { emoji: '🐵', name: 'monkey face' },
      { emoji: '🙈', name: 'see-no-evil monkey' },
      { emoji: '🙉', name: 'hear-no-evil monkey' },
      { emoji: '🙊', name: 'speak-no-evil monkey' },
      { emoji: '🐒', name: 'monkey' },
      { emoji: '🐔', name: 'chicken' },
      { emoji: '🐧', name: 'penguin' },
      { emoji: '🐦', name: 'bird' },
      { emoji: '🐤', name: 'baby chick' },
      { emoji: '🐣', name: 'hatching chick' },
      { emoji: '🐥', name: 'front-facing baby chick' },
      { emoji: '🦆', name: 'duck' },
      { emoji: '🦅', name: 'eagle' },
      { emoji: '🦉', name: 'owl' },
      { emoji: '🦇', name: 'bat' }
    ]
  },
  {
    label: 'Food & Drink',
    items: [
      { emoji: '🍎', name: 'red apple' },
      { emoji: '🍏', name: 'green apple' },
      { emoji: '🍊', name: 'tangerine' },
      { emoji: '🍋', name: 'lemon' },
      { emoji: '🍌', name: 'banana' },
      { emoji: '🍉', name: 'watermelon' },
      { emoji: '🍇', name: 'grapes' },
      { emoji: '🍓', name: 'strawberry' },
      { emoji: '🫐', name: 'blueberries' },
      { emoji: '🍈', name: 'melon' },
      { emoji: '🍒', name: 'cherries' },
      { emoji: '🍑', name: 'peach' },
      { emoji: '🥭', name: 'mango' },
      { emoji: '🍍', name: 'pineapple' },
      { emoji: '🥥', name: 'coconut' },
      { emoji: '🥝', name: 'kiwi fruit' },
      { emoji: '🍅', name: 'tomato' },
      { emoji: '🍆', name: 'eggplant' },
      { emoji: '🥑', name: 'avocado' },
      { emoji: '🥦', name: 'broccoli' },
      { emoji: '🥬', name: 'leafy greens' },
      { emoji: '🥒', name: 'cucumber' },
      { emoji: '🌶️', name: 'hot pepper' },
      { emoji: '🫑', name: 'bell pepper' },
      { emoji: '🌽', name: 'ear of corn' },
      { emoji: '🥕', name: 'carrot' },
      { emoji: '🫒', name: 'olive' },
      { emoji: '🧄', name: 'garlic' },
      { emoji: '🧅', name: 'onion' },
      { emoji: '🥔', name: 'potato' }
    ]
  }
]

const textValue = shallowRef('')
const searchValue = shallowRef('')
const textInput = useTemplateRef<HTMLInputElement>('textInput')

async function insertEmoji(emoji: string) {
  const input = textInput.value
  if (!input) return
  const start = input.selectionStart ?? textValue.value.length
  const end = input.selectionEnd ?? textValue.value.length
  const caret = start + emoji.length
  textValue.value = textValue.value.slice(0, start) + emoji + textValue.value.slice(end)
  await nextTick()
  textInput.value?.focus()
  textInput.value?.setSelectionRange(caret, caret)
}
</script>

<template>
  <div class="mx-auto w-64">
    <div class="flex items-center gap-2">
      <input
        ref="textInput"
        v-model="textValue"
        type="text"
        class="h-8 flex-1 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"
        placeholder="Reply to the discussion"
      />

      <Autocomplete.Root
        :items="emojiGroups"
        :item-to-string-value="(item: EmojiItem) => item.name"
        :value="searchValue"
        @update:value="() => {}"
        grid
        @open-change-complete="
          (isOpen: boolean) => {
            if (!isOpen) searchValue = ''
          }
        "
      >
        <Autocomplete.Trigger
          class="size-8 rounded-md border border-gray-200 bg-gray-50 text-xl text-gray-900 outline-hidden hover:bg-gray-100 focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-gray-950 data-popup-open:bg-gray-100"
          aria-label="Choose emoji"
        >
          😀
        </Autocomplete.Trigger>

        <Autocomplete.Portal>
          <Autocomplete.Positioner class="outline-hidden" :side-offset="4" align="end">
            <Autocomplete.Popup
              aria-label="Select emoji"
              class="max-h-82 max-w-(--available-width) origin-(--transform-origin) rounded-lg bg-gray-50 text-gray-900 shadow-lg outline-1 outline-gray-200 transition-[transform,scale,opacity] [--input-container-height:3rem] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0"
            >
              <div
                class="mx-1 flex h-(--input-container-height) w-64 items-center justify-center bg-gray-50 text-center"
              >
                <Autocomplete.Input
                  :on-input="
                    (event: Event) =>
                      (searchValue = (event.currentTarget as HTMLInputElement).value)
                  "
                  placeholder="Search emojis…"
                  class="h-8 w-64 max-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>
              <Autocomplete.Empty>
                <div class="px-2 py-3 text-sm/4 text-gray-600">No emojis found</div>
              </Autocomplete.Empty>
              <Autocomplete.List
                class="max-h-[min(calc(20.5rem-var(--input-container-height)),calc(var(--available-height)-var(--input-container-height)))] scroll-pt-10 scroll-pb-1.5 overflow-auto overscroll-contain"
              >
                <Autocomplete.Collection v-slot="{ item: group }">
                  <Autocomplete.Group class="block">
                    <Autocomplete.GroupLabel
                      class="sticky top-0 z-1 m-0 w-full border-b border-gray-100 bg-gray-50 px-2 pt-2 pb-1 text-xs font-semibold tracking-wide text-gray-600 uppercase"
                    >
                      {{ (group as EmojiGroup).label }}
                    </Autocomplete.GroupLabel>
                    <div class="p-1" role="presentation">
                      <Autocomplete.Row
                        v-for="(row, rowIdx) in chunk((group as EmojiGroup).items, COLUMNS)"
                        :key="`${(group as EmojiGroup).label}-${rowIdx}`"
                        class="grid grid-cols-5"
                      >
                        <Autocomplete.Item
                          v-for="item in row"
                          :key="item.name"
                          :value="item"
                          :on-click="() => insertEmoji(item.emoji)"
                          class="flex h-10 min-w-(--anchor-width) flex-col items-center justify-center rounded-md bg-transparent px-0.5 py-2 text-gray-900 outline-hidden select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-md data-highlighted:before:bg-gray-200"
                        >
                          <span class="text-2xl leading-none">{{ item.emoji }}</span>
                        </Autocomplete.Item>
                      </Autocomplete.Row>
                    </div>
                  </Autocomplete.Group>
                </Autocomplete.Collection>
              </Autocomplete.List>
            </Autocomplete.Popup>
          </Autocomplete.Positioner>
        </Autocomplete.Portal>
      </Autocomplete.Root>
    </div>
  </div>
</template>

Pressing an item fills the input with that item's label, which would blank the grid down to the pressed emoji while the popup animates away. The demo avoids it by passing a one-way :value with an @update:value listener that refuses every write, and re-supplying the query from the input's own @input, which runs before the component's handler. Value changes that arrive without a typing event never reach the query in this shape (the clear button, Escape restoring the pre-open query, inline completion, browser autofill), which is why the reset lives in openChangeComplete rather than in the setter.

Virtualized

Render only the visible rows for large lists.

<script setup lang="ts">
import { Autocomplete } from '@shardsui/vue/autocomplete'
import { computed, shallowRef, 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(5, '0')}` }
})

const filter = Autocomplete.createFilter()
const value = shallowRef('')

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

const filteredItems = computed(() =>
  value.value.trim() === ''
    ? items
    : items.filter((item) => filter.contains(item.name, value.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>
  <Autocomplete.Root
    virtualized
    v-model:value="value"
    :filtered-items="filteredItems"
    :filter="null"
    :item-to-string-value="(item: Item) => item.name"
    @item-highlighted="scrollHighlightedIntoView"
  >
    <label class="flex flex-col gap-1 text-sm/5 font-semibold text-gray-900">
      Search 10,000 items
      <Autocomplete.Input
        placeholder="Type to filter…"
        class="h-8 w-64 rounded-md border border-gray-200 bg-gray-50 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"
      />
    </label>

    <Autocomplete.Portal>
      <Autocomplete.Positioner class="outline-hidden" :side-offset="4">
        <Autocomplete.Popup
          class="max-h-[min(22.5rem,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"
        >
          <Autocomplete.Empty>
            <div class="px-2 py-3 text-sm/4 text-gray-600">No results found.</div>
          </Autocomplete.Empty>
          <Autocomplete.List class="p-0">
            <div
              ref="scrollEl"
              role="presentation"
              class="h-[min(22.5rem,var(--total-size))] max-h-(--available-height) 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` }"
                >
                  <Autocomplete.Item
                    v-for="(item, i) in slice"
                    :key="item.id"
                    :index="start + i"
                    :value="item"
                    :aria-setsize="count"
                    :aria-posinset="start + i + 1"
                    class="flex 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` }"
                  >
                    {{ item.name }}
                  </Autocomplete.Item>
                </div>
              </div>
            </div>
          </Autocomplete.List>
        </Autocomplete.Popup>
      </Autocomplete.Positioner>
    </Autocomplete.Portal>
  </Autocomplete.Root>
</template>

API reference

Root

Groups all parts of the autocomplete. Doesn't render its own HTML element, but renders a hidden <input> beside.

PropTypeDefault

Other parts

Input, InputGroup, Trigger, Icon, Clear, Portal, Backdrop, Positioner, Popup, Arrow, List, Collection, Group, GroupLabel, Empty, Status and Row are the Combobox parts. See that page for their props and data attributes. InputGroup and Trigger are the exception: autocomplete never holds a selection, so their placeholder state and data-placeholder attribute never apply. Separator is a visual divider rendered as role="presentation", because role="separator" is not valid inside a listbox.

Item

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

PropTypeDefault
AttributeDescription
data-highlightedPresent when the item is highlighted.
data-disabledPresent when the item is disabled.

Value

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

PropTypeDefault

createFilter

A locale-aware filter helper returning contains / startsWith / endsWith predicates built around Intl.Collator. See the Combobox createFilter docs. It takes AutocompleteFilterOptionsIntl.CollatorOptions plus locale — and returns an AutocompleteFilter. The multiple and value options are Combobox-only, since an autocomplete has no selected item.

<script setup>
import { computed } from 'vue'
import { Autocomplete } from '@shardsui/vue/autocomplete'

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