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
itemsfor 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 theFieldparts 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
Async search
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.
| Prop | Type | Default | |
|---|---|---|---|
value | string | '' | |
onValueChange | (value: string) => void | — | |
open | boolean | false | |
onOpenChange | (open: boolean) => void | — | |
onOpenChangeComplete | (open: boolean) => void | — | |
disabled | boolean | false | |
readOnly | boolean | false | |
required | boolean | false | |
modal | boolean | false | |
loopFocus | boolean | true | |
openOnInputClick | boolean | false | |
mode | 'list' | 'both' | 'inline' | 'none' | 'list' | |
inline | boolean | false | |
autoHighlight | boolean | 'always' | false | |
keepHighlight | boolean | false | |
highlightItemOnHover | boolean | true | |
onItemHighlighted | (value: unknown | undefined, reason: 'keyboard' | 'pointer' | 'none', index: number) => void | — | |
submitOnItemClick | boolean | false | |
itemToStringValue | (item) => string | — | |
name | string | — | |
form | string | — | |
grid | boolean | false | |
items | readonly Value[] | readonly { items: Value[] }[] | — | |
filter | ((item, query, itemToString?) => boolean) | null | — | |
filteredItems | readonly Value[] | readonly { items: Value[] }[] | — | |
limit | number | -1 | |
locale | Intl.LocalesArgument | — | |
virtualized | boolean | false | |
id | string | — | |
children | Snippet | — | |
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.
| Prop | Type | Default | |
|---|---|---|---|
as | keyof HTMLElementTagNameMap | 'div' | |
class | string | — | |
style | string | — | |
value | unknown | null | |
disabled | boolean | false | |
index | number | — | |
onclick | (event: MouseEvent) => void | — | |
children | Snippet<[{ highlighted, disabled }]> | — | |
| Attribute | Description |
|---|---|
data-highlighted | Present when the item is highlighted. |
data-disabled | Present when the item is disabled. |
Value
The current value of the autocomplete. Doesn't render its own HTML element.
| Prop | Type | Default | |
|---|---|---|---|
children | Snippet<[value: string]> | — | |
createFilter
A locale-aware filter helper returning contains / startsWith / endsWith predicates built around Intl.Collator. See the Combobox createFilter docs. It takes AutocompleteFilterOptions — Intl.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>