A searchable selection component with filtering capabilities, built on top of Reka UI Combobox.
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useFilter } from 'reka-ui'
import { ChevronDownIcon, SearchIcon } from 'lucide-vue-next'
import {
Autocomplete,
AutocompleteContent,
AutocompleteControl,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
AutocompleteTrigger,
AutocompleteClear,
AutocompleteLabel,
AutocompleteLoading,
AutocompleteGroup,
} from '~/components/ui/autocomplete'
const items = ['Vue', 'React', 'Svelte', 'Angular', 'Solid', 'Nuxt', 'Next.js']
const searchTerm = ref('')
const value = ref<string>()
const isLoading = ref(false)
const { contains } = useFilter({ sensitivity: 'base' })
const filteredItems = computed(() => {
if (!searchTerm.value) return items
return items.filter(item => contains(item, searchTerm.value))
})
let timer: ReturnType<typeof setTimeout>
watch(searchTerm, newVal => {
if (newVal) {
isLoading.value = true
clearTimeout(timer)
timer = setTimeout(() => {
isLoading.value = false
}, 250)
} else {
isLoading.value = false
}
})
</script>
<template>
<Autocomplete v-model="value" v-model:search-term="searchTerm">
<AutocompleteControl>
<SearchIcon class="size-4 ms-2" />
<AutocompleteInput placeholder="Search..." />
<AutocompleteClear />
<AutocompleteTrigger as-child>
<div class="flex items-center pl-2">
<ChevronDownIcon class="size-4 me-2" />
</div>
</AutocompleteTrigger>
</AutocompleteControl>
<AutocompleteContent>
<AutocompleteLoading v-if="isLoading" />
<template v-else>
<AutocompleteEmpty>No framework found.</AutocompleteEmpty>
<AutocompleteList>
<AutocompleteGroup>
<AutocompleteLabel>Framework</AutocompleteLabel>
<AutocompleteItem v-for="item in filteredItems" :key="item" :value="item">
{{ item }}
</AutocompleteItem>
</AutocompleteGroup>
</AutocompleteList>
</template>
</AutocompleteContent>
</Autocomplete>
</template>The Autocomplete allows you to create a searchable selection component with filtering capabilities. It is built on top of Reka UI's Combobox primitive.
Installation
pnpm dlx sulaf@latest add autocomplete
Usage
<script setup lang="ts">
import {
Autocomplete,
AutocompleteContent,
AutocompleteControl,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
} from '@/components/ui/autocomplete'
</script>
<template>
<Autocomplete>
<AutocompleteControl>
<AutocompleteInput placeholder="Search..." />
</AutocompleteControl>
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
<AutocompleteItem value="item-1">Item 1</AutocompleteItem>
<AutocompleteItem value="item-2">Item 2</AutocompleteItem>
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
</template>Examples
Multi-select
Bind to an array to allow multiple selections. Use the multiple prop on the root component.
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useFilter } from 'reka-ui'
import { ChevronDownIcon, SearchIcon, XIcon } from 'lucide-vue-next'
import {
Autocomplete,
AutocompleteContent,
AutocompleteControl,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
AutocompleteTrigger,
AutocompleteClear,
AutocompleteLabel,
AutocompleteGroup,
} from '~/components/ui/autocomplete'
import { Badge } from '~/components/ui/badge'
const items = ['Vue', 'React', 'Svelte', 'Angular', 'Solid', 'Nuxt', 'Next.js']
const searchTerm = ref('')
const value = ref<string[]>([])
const { contains } = useFilter({ sensitivity: 'base' })
const filteredItems = computed(() => {
if (!searchTerm.value) return items
return items.filter(item => contains(item, searchTerm.value))
})
function removeItem(item: string) {
value.value = value.value.filter(v => v !== item)
}
</script>
<template>
<div class="w-full max-w-md space-y-4">
<div v-if="value.length > 0" class="flex flex-wrap gap-2">
<Badge v-for="v in value" :key="v" variant="secondary" class="gap-1">
{{ v }}
<button
type="button"
class="outline-none"
:aria-label="`Remove ${v}`"
@click="removeItem(v)"
>
<XIcon class="size-3" />
</button>
</Badge>
</div>
<Autocomplete v-model="value" v-model:search-term="searchTerm" multiple>
<AutocompleteControl>
<SearchIcon class="size-4 ms-2" />
<AutocompleteInput placeholder="Select frameworks..." />
<AutocompleteClear />
<AutocompleteTrigger as-child>
<div class="flex items-center pl-2">
<ChevronDownIcon class="size-4 me-2" />
</div>
</AutocompleteTrigger>
</AutocompleteControl>
<AutocompleteContent>
<AutocompleteEmpty>No framework found.</AutocompleteEmpty>
<AutocompleteList>
<AutocompleteGroup>
<AutocompleteLabel>Frameworks</AutocompleteLabel>
<AutocompleteItem v-for="item in filteredItems" :key="item" :value="item">
{{ item }}
</AutocompleteItem>
</AutocompleteGroup>
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
</div>
</template>Async Search
Handle search terms manually to fetch data from an external API.
<script setup lang="ts">
import { ref, watch } from 'vue'
import { SearchIcon, Loader2Icon } from 'lucide-vue-next'
import {
Autocomplete,
AutocompleteContent,
AutocompleteControl,
AutocompleteEmpty,
AutocompleteInput,
AutocompleteItem,
AutocompleteList,
AutocompleteLoading,
} from '~/components/ui/autocomplete'
interface User {
id: number
firstName: string
username: string
}
const searchTerm = ref('')
const value = ref<User>()
const items = ref<User[]>([])
const isLoading = ref(false)
const isOpen = ref(false)
// Store the current AbortController instance
let abortController: AbortController | null = null
async function fetchUsers(query: string) {
// Cancel any ongoing request
if (abortController) {
abortController.abort()
}
if (!query) {
items.value = []
isLoading.value = false
return
}
// Create a new AbortController for this request
const controller = new AbortController()
abortController = controller
isLoading.value = true
try {
const response = await fetch(`https://dummyjson.com/users`, {
signal: controller.signal,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data: User[] = (await response.json())?.users ?? []
items.value = data.filter(
user =>
user.firstName.toLowerCase().includes(query.toLowerCase()) ||
user.username.toLowerCase().includes(query.toLowerCase()),
)
} catch (error) {
// Ignore abort errors as they're intentional
if (error instanceof Error && error.name === 'AbortError') {
return
}
// eslint-disable-next-line no-console
console.error('[Demo]: Failed to fetch users:', error)
} finally {
// Only clear state if we're still the active request
if (abortController === controller) {
isLoading.value = false
abortController = null
}
}
}
watch(searchTerm, newVal => {
fetchUsers(newVal)
})
</script>
<template>
<div class="w-full max-w-md">
<Autocomplete v-model="value" v-model:open="isOpen" v-model:search-term="searchTerm">
<AutocompleteControl>
<SearchIcon v-if="!isLoading" class="size-4 ms-2" />
<Loader2Icon v-else class="size-4 ms-2 animate-spin" />
<AutocompleteInput placeholder="Search users by name..." />
</AutocompleteControl>
<AutocompleteContent>
<AutocompleteLoading v-if="isLoading"> Searching for users... </AutocompleteLoading>
<template v-else>
<AutocompleteEmpty v-if="searchTerm && items.length === 0">
No users found for "{{ searchTerm }}".
</AutocompleteEmpty>
<AutocompleteList v-if="items.length > 0">
<AutocompleteItem v-for="user in items" :key="user.id" :value="user">
<div class="flex flex-col">
<span>{{ user.firstName }}</span>
<span class="text-xs text-muted-foreground">@{{ user.username }}</span>
</div>
</AutocompleteItem>
</AutocompleteList>
</template>
</AutocompleteContent>
</Autocomplete>
<div v-if="value" class="mt-4 text-sm text-muted-foreground">
Selected:
<span class="font-medium text-foreground">{{ value.firstName }}</span>
</div>
</div>
</template>API Reference
The Autocomplete component uses Reka UI's Combobox primitive under the hood, but introduces a few custom abstractions, models, and styling defaults.
Autocomplete (Root)
The root component extends all ComboboxRoot properties and adds semantic handling for the search input state.
| Prop | Type | Default | Description |
|---|---|---|---|
v-model:search-term | string | '' | Exposes the direct value of the AutocompleteInput. By binding to this, you can filter your own item lists dynamically outside the component. |
Accessibility
The Autocomplete component follows the WAI-ARIA Combobox pattern.
Keyboard Interactions
| Key | Action |
|---|---|
ArrowDown | Navigates to the next item in the list. |
ArrowUp | Navigates to the previous item in the list. |
Enter | Selects the currently focused item. |
Escape | Closes the list panel. |
Home / End | Moves focus to the first or last item in the list. |
ARIA Attributes
- The input uses
aria-autocomplete="list",aria-expanded, andaria-controlsto manage the relationship with the dropdown. AutocompleteLoadingincludesaria-live="polite"to announce loading states to screen readers.AutocompleteClearandAutocompleteTriggerinclude defaultaria-labelattributes for semantic clarity.
Labeling
Always provide a label for the input for better accessibility. You can use an external <label> or pass aria-label to the AutocompleteInput.
<AutocompleteInput aria-label="Select a framework" />