ShardsUI is in beta. APIs may change before 1.0.

Autocomplete

An input with type-ahead suggestions.

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

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

<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" sideOffset={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>
            {#snippet children(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>
            {/snippet}
          </Autocomplete.Collection>
        </Autocomplete.List>
      </Autocomplete.Popup>
    </Autocomplete.Positioner>
  </Autocomplete.Portal>
</Autocomplete.Root>

Anatomy

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

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

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.
  • Can be used for filterable command pickers: the input can filter a list of command items that perform an action when clicked, rendered inside the popup.
  • 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. The autocomplete's own value is always a string — the input's text.

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

Filtering

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

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

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

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

<Autocomplete.Root items={tags} bind:value>
  <Autocomplete.InputGroup>
    <Autocomplete.Input />
  </Autocomplete.InputGroup>

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

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

Examples

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

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

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

  let value = $state('')
  let results = $state<Page[]>([])
  let error = $state<string | null>(null)
  let pending = $state(false)

  let requestId = 0

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

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

    pending = true
    error = null

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

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

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

<Autocomplete.Root
  items={results}
  filter={null}
  itemToStringValue={(page: Page) => page.title}
  bind:value
  onValueChange={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" sideOffset={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>
          {#if pending}
            <div 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>
          {:else if status}
            <div class="py-1 pr-8 pl-2 text-sm text-gray-600">{status}</div>
          {/if}
        </Autocomplete.Status>
        <Autocomplete.List>
          <Autocomplete.Collection>
            {#snippet children(page: Page)}
              <Autocomplete.Item
                value={page}
                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">{page.title}</span>
                  <span class="text-sm/4 opacity-80">{page.section}</span>
                </span>
              </Autocomplete.Item>
            {/snippet}
          </Autocomplete.Collection>
        </Autocomplete.List>
      </Autocomplete.Popup>
    </Autocomplete.Positioner>
  </Autocomplete.Portal>
</Autocomplete.Root>

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 lang="ts">
  import { Autocomplete } from '@shardsui/svelte/autocomplete'

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

<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" sideOffset={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>
            {#snippet children(topic: string)}
              <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>
            {/snippet}
          </Autocomplete.Collection>
        </Autocomplete.List>
      </Autocomplete.Popup>
    </Autocomplete.Positioner>
  </Autocomplete.Portal>
</Autocomplete.Root>

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

<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" sideOffset={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>
            {#snippet children(subject: Subject)}
              <Autocomplete.Group items={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"
                >
                  {subject.value}
                </Autocomplete.GroupLabel>
                <Autocomplete.Collection>
                  {#snippet children(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>
                  {/snippet}
                </Autocomplete.Collection>
              </Autocomplete.Group>
            {/snippet}
          </Autocomplete.Collection>
        </Autocomplete.List>
      </Autocomplete.Popup>
    </Autocomplete.Positioner>
  </Autocomplete.Portal>
</Autocomplete.Root>

Fuzzy matching

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

<script lang="ts">
  import { Autocomplete } from '@shardsui/svelte/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>

<Autocomplete.Root {items} filter={fuzzyFilter} itemToStringValue={(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" sideOffset={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>
            {#snippet children(item: 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.title}</span>
                <span class="text-sm/5 text-gray-600">{item.summary}</span>
              </Autocomplete.Item>
            {/snippet}
          </Autocomplete.Collection>
        </Autocomplete.List>
      </Autocomplete.Popup>
    </Autocomplete.Positioner>
  </Autocomplete.Portal>
</Autocomplete.Root>

Limit results

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

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

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

  let value = $state('')

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

<Autocomplete.Root items={tags} bind:value {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" sideOffset={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>
            {#snippet children(tag: string)}
              <Autocomplete.Item
                value={tag}
                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"
              >
                {tag}
              </Autocomplete.Item>
            {/snippet}
          </Autocomplete.Collection>
        </Autocomplete.List>
        <Autocomplete.Status>
          {#if hiddenCount > 0}
            <div 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>
          {/if}
        </Autocomplete.Status>
      </Autocomplete.Popup>
    </Autocomplete.Positioner>
  </Autocomplete.Portal>
</Autocomplete.Root>

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 lang="ts">
  import { Autocomplete } from '@shardsui/svelte/autocomplete'

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

<Autocomplete.Root items={statuses} autoHighlight>
  <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" sideOffset={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>
            {#snippet children(status: string)}
              <Autocomplete.Item
                value={status}
                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"
              >
                {status}
              </Autocomplete.Item>
            {/snippet}
          </Autocomplete.Collection>
        </Autocomplete.List>
      </Autocomplete.Popup>
    </Autocomplete.Positioner>
  </Autocomplete.Portal>
</Autocomplete.Root>

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 lang="ts">
  import { Autocomplete } from '@shardsui/svelte/autocomplete'
  import { Dialog } from '@shardsui/svelte/dialog'
  import { ScrollArea } from '@shardsui/svelte/scroll-area'

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

  let open = $state(false)

  function onkeydown(event: KeyboardEvent) {
    if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
      event.preventDefault()
      open = true
    }
  }
</script>

<svelte:window {onkeydown} />

<Dialog.Root bind: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 autoHighlight="always" keepHighlight>
          <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>
                    {#snippet children(group: Group)}
                      <Autocomplete.Group items={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.value}
                        </Autocomplete.GroupLabel>
                        <Autocomplete.Collection>
                          {#snippet children(command: string)}
                            <Autocomplete.Item
                              value={command}
                              onclick={() => (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.kind}
                              </span>
                            </Autocomplete.Item>
                          {/snippet}
                        </Autocomplete.Collection>
                      </Autocomplete.Group>
                    {/snippet}
                  </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>

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 lang="ts">
  import { tick } from 'svelte'
  import { Autocomplete } from '@shardsui/svelte/autocomplete'

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

  let textValue = $state('')
  let searchValue = $state('')
  let textInputEl = $state<HTMLInputElement | null>(null)

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

<div class="mx-auto w-64">
  <div class="flex items-center gap-2">
    <input
      bind:this={textInputEl}
      bind:value={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}
      itemToStringValue={(item: EmojiItem) => item.name}
      bind:value={() => searchValue, () => {}}
      grid
      onOpenChangeComplete={(isOpen) => {
        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" sideOffset={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
                oninput={(event) => (searchValue = event.currentTarget.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>
                {#snippet children(group: EmojiGroup)}
                  <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.label}
                    </Autocomplete.GroupLabel>
                    <div class="p-1" role="presentation">
                      {#each chunk(group.items, COLUMNS) as row, rowIdx (`${group.label}-${rowIdx}`)}
                        <Autocomplete.Row class="grid grid-cols-5">
                          {#each row as item (item.name)}
                            <Autocomplete.Item
                              value={item}
                              onclick={() => 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>
                          {/each}
                        </Autocomplete.Row>
                      {/each}
                    </div>
                  </Autocomplete.Group>
                {/snippet}
              </Autocomplete.Collection>
            </Autocomplete.List>
          </Autocomplete.Popup>
        </Autocomplete.Positioner>
      </Autocomplete.Portal>
    </Autocomplete.Root>
  </div>
</div>

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 binding value to a setter that refuses every write — bind:value={() => searchValue, () => {}} — and re-supplying the query from the input's own oninput, 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 onOpenChangeComplete rather than in the setter.

Virtualized

Efficiently handle large datasets by rendering only visible rows.

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

  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()
  let value = $state('')

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

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

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

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

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

<Autocomplete.Root
  virtualized
  bind:value
  {filteredItems}
  filter={null}
  itemToStringValue={(item) => item.name}
  onItemHighlighted={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" sideOffset={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
            role="presentation"
            bind:this={scrollEl}
            class="h-[min(22.5rem,var(--total-size))] max-h-(--available-height) overflow-auto overscroll-contain"
            style:--total-size="{totalHeight}px"
            {onscroll}
          >
            <div role="presentation" class="relative w-full" style:height="{totalHeight}px">
              <div role="presentation" class="absolute inset-x-0" style:top="{offsetTop}px">
                {#each slice as item, i (item.id)}
                  <Autocomplete.Item
                    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>
                {/each}
              </div>
            </div>
          </div>
        </Autocomplete.List>
      </Autocomplete.Popup>
    </Autocomplete.Positioner>
  </Autocomplete.Portal>
</Autocomplete.Root>

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>
  import { Autocomplete } from '@shardsui/svelte/autocomplete'

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