Autocomplete
A text field that offers suggestions without insisting on them: free text, a listbox, arrows and Enter.
The free-text half of the combobox pattern, and the reason both exist: here whatever is typed is the value and the listbox is an offer, so a tag, an address or a search term no source knows about is still a legal answer — onSelect fires only when a suggestion is actually taken. Combobox is the other half, for a field whose value must be one of a known set. The input is the combobox and the panel is the listbox: aria-expanded, aria-controls, aria-autocomplete="list" and aria-activedescendant all sit on the input, so focus never leaves the field and the arrow keys move a highlight rather than the caret. Down and Up wrap at both ends and open the panel if it is shut; Enter takes the highlighted suggestion and nothing else, so a field with no highlight leaves Enter to the form around it; Escape closes and keeps what was typed. A live region announces the count as it changes — "3 suggestions" — rather than each option being read out on the way past. The query is debounced through useDebouncedValue, so a run of keystrokes asks the source once, and the answer is stored with the query it answered: a slow response the reader has already typed past is dropped rather than replacing a newer one. Until an answer arrives for the text as it now stands, the list on show stays up but busy and dimmed, and nothing in it can be highlighted, clicked or taken with Enter. A source that rejects reads as no matches, because a listbox is not the place to report a failure — catch it inside getSuggestions and raise a notify.error from there. As a composite input, the remaining input props (id, name, placeholder, required, aria-*) go to the field itself; className is the wrapper's. onSelect here is the suggestion that was taken, not the DOM select event, so it replaces the input prop of that name rather than intersecting with it.
Install
npx shadcn@latest add @vibra/autocompleteNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Autocomplete, type AutocompleteSuggestion } from "@/components/ui/autocomplete"
import { Label } from "@/components/ui/label"
const TAGS: AutocompleteSuggestion[] = [
{ value: "billing", label: "billing", description: "412 issues" },
{ value: "billing/invoices", label: "billing/invoices", description: "88 issues" },
{ value: "bug", label: "bug", description: "1,204 issues" },
{ value: "design", label: "design", description: "96 issues" },
{ value: "docs", label: "docs", description: "233 issues" },
{ value: "infra", label: "infra", description: "310 issues" },
{ value: "onboarding", label: "onboarding", description: "57 issues" },
]
export default function AutocompleteDemo() {
const [value, setValue] = React.useState("")
const [tags, setTags] = React.useState<string[]>(["bug"])
return (
<div className="flex w-full max-w-sm flex-col gap-3 self-start">
<Label htmlFor="tag">Tag</Label>
<Autocomplete
id="tag"
value={value}
onValueChange={setValue}
getSuggestions={(query) =>
TAGS.filter((tag) => tag.label.includes(query.toLowerCase()))
}
onSelect={(suggestion) => {
setTags((current) =>
current.includes(suggestion.value) ? current : [...current, suggestion.value]
)
setValue("")
}}
placeholder="Type to search, or invent one"
/>
<p className="text-xs text-muted-foreground">
A tag nobody has used yet is a valid answer — press Enter on your own text.
</p>
<ul className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<li
key={tag}
className="rounded-full bg-secondary px-2 py-0.5 font-mono text-xs text-secondary-foreground"
>
{tag}
</li>
))}
</ul>
</div>
)
}From a search endpoint
Two letters start the search, a round trip answers it, and a run of keystrokes asks once.
"use client"
import * as React from "react"
import { Autocomplete, type AutocompleteSuggestion } from "@/components/ui/autocomplete"
import { Label } from "@/components/ui/label"
const CUSTOMERS: AutocompleteSuggestion[] = [
{ value: "Aurora Metrics", label: "Aurora Metrics", description: "Berlin · 240 seats" },
{ value: "Beacon Logistics", label: "Beacon Logistics", description: "Rotterdam · 1,100 seats" },
{ value: "Cinder Robotics", label: "Cinder Robotics", description: "Osaka · 92 seats" },
{ value: "Meridian Foods", label: "Meridian Foods", description: "Lyon · 3,400 seats" },
{ value: "Nordic Supply", label: "Nordic Supply", description: "Oslo · 610 seats" },
{ value: "Volta Labs", label: "Volta Labs", description: "Austin · 45 seats" },
]
/** Stands in for the search endpoint: a round trip, then the matches. */
function search(query: string): Promise<AutocompleteSuggestion[]> {
return new Promise((resolve) =>
setTimeout(
() =>
resolve(
CUSTOMERS.filter((customer) =>
customer.label.toLowerCase().includes(query.toLowerCase())
)
),
450
)
)
}
export default function AutocompleteAsync() {
const [value, setValue] = React.useState("")
const [picked, setPicked] = React.useState<AutocompleteSuggestion | null>(null)
return (
<div className="flex w-full max-w-sm flex-col gap-3 self-start">
<Label htmlFor="customer">Customer</Label>
<Autocomplete
id="customer"
value={value}
onValueChange={setValue}
getSuggestions={search}
onSelect={setPicked}
minLength={2}
debounce={300}
emptyText="No customer by that name"
placeholder="Search customers"
/>
<p className="text-xs text-muted-foreground">
{picked
? `${picked.label} — ${picked.description}`
: "Two letters start the search, and a run of keystrokes asks once."}
</p>
</div>
)
}Search field
Recent searches before anything is typed, a clear button once something is, and Enter searches the words as typed.
"use client"
import * as React from "react"
import { SearchIcon, XIcon } from "lucide-react"
import { Autocomplete, type AutocompleteSuggestion } from "@/components/ui/autocomplete"
import { Button } from "@/components/ui/button"
const RECENT: AutocompleteSuggestion[] = [
{ value: "overdue", label: "overdue", description: "Recent search" },
{ value: "INV-2041", label: "INV-2041", description: "Recent search" },
]
const INDEX: AutocompleteSuggestion[] = [
{ value: "Blue Harbor Logistics", label: "Blue Harbor Logistics", description: "Customer · 14 invoices" },
{ value: "INV-2041", label: "INV-2041", description: "Blue Harbor Logistics · $1,240.00 · overdue" },
{ value: "INV-2038", label: "INV-2038", description: "Blue Harbor Logistics · $980.00 · paid" },
{ value: "Harbour renewal", label: "Harbour renewal", description: "Project · 6 invoices" },
]
// A search box: recent searches before anything is typed (minLength 0), a
// clear button once something is, and Enter searches for whatever is in the
// field — only a highlighted suggestion takes Enter for itself.
export default function AutocompleteSearch() {
const field = React.useRef<HTMLInputElement>(null)
const [value, setValue] = React.useState("")
const [searched, setSearched] = React.useState("")
return (
<form
role="search"
className="flex w-full max-w-sm flex-col gap-2 self-start"
onSubmit={(event) => {
event.preventDefault()
if (value.trim()) setSearched(value.trim())
}}
>
<div className="relative">
<SearchIcon aria-hidden="true" className="pointer-events-none absolute start-2.5 top-2 z-10 size-4 text-muted-foreground" />
<Autocomplete
ref={field}
aria-label="Search invoices"
value={value}
onValueChange={setValue}
minLength={0}
getSuggestions={(query) =>
query === "" ? RECENT : INDEX.filter((item) => item.label.toLowerCase().includes(query.toLowerCase()))
}
onSelect={(suggestion) => setSearched(suggestion.value)}
placeholder="Search invoices"
className="[&_input]:ps-8 [&_input]:pe-8"
/>
{value ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Clear the search"
className="absolute end-1 top-1"
onClick={() => {
setValue("")
field.current?.focus()
}}
>
<XIcon aria-hidden="true" />
</Button>
) : null}
</div>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{searched ? `Showing invoices for “${searched}”.` : "Press Enter to search for what you typed."}
</p>
</form>
)
}Completes what you type
Suggestions built from the typing itself; an address they don't cover opens no panel, and a half one says what is missing.
"use client"
import * as React from "react"
import { isEmail } from "@/lib/validation"
import { Autocomplete } from "@/components/ui/autocomplete"
import { Button } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
const DOMAINS = ["northwind.example", "blueharbor.example", "kestrel.example"]
// Suggestions made from what is typed rather than looked up: the part before
// the @ with each domain the workspace knows. Taking one completes the field;
// an address outside them matches nothing, and with emptyText="" no panel
// opens — typing it in full is a legal answer. Enter with nothing highlighted
// invites what is there, and a half address says what is missing.
export default function AutocompleteEmail() {
const id = React.useId()
const [value, setValue] = React.useState("")
const [invited, setInvited] = React.useState<string[]>([])
const [problem, setProblem] = React.useState<string | null>(null)
function invite(address: string) {
if (!isEmail(address)) {
setProblem("Enter the whole address, like saoirse@northwind.example.")
return
}
setProblem(null)
setInvited((current) => (current.includes(address) ? current : [...current, address]))
setValue("")
}
return (
<form
noValidate
className="flex w-full max-w-sm flex-col gap-2 self-start"
onSubmit={(event) => {
event.preventDefault()
invite(value.trim())
}}
>
<Field data-invalid={problem ? true : undefined}>
<FieldLabel htmlFor={`${id}-email`}>Invite by email</FieldLabel>
<div className="flex gap-2">
<Autocomplete
id={`${id}-email`}
type="email"
value={value}
onValueChange={(next) => {
setValue(next)
setProblem(null)
}}
getSuggestions={(query) => {
const [name, domain = "", extra] = query.split("@")
if (!name || extra !== undefined) return []
return DOMAINS.filter((known) => known.startsWith(domain) && `${name}@${known}` !== query).map(
(known) => ({ value: `${name}@${known}`, label: `${name}@${known}` })
)
}}
emptyText=""
debounce={0}
placeholder="name@example.com"
aria-invalid={problem ? true : undefined}
aria-describedby={problem ? `${id}-problem` : undefined}
/>
<Button type="submit" variant="outline">
Invite
</Button>
</div>
<FieldError id={`${id}-problem`}>{problem}</FieldError>
</Field>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{invited.length ? `Invited ${invited.join(", ")}.` : "Nobody invited yet."}
</p>
</form>
)
}Long list
The panel stops at its own height and scrolls, the highlight stays in view, and Home and End reach either end.
"use client"
import * as React from "react"
import { Autocomplete, type AutocompleteSuggestion } from "@/components/ui/autocomplete"
import { Label } from "@/components/ui/label"
const CITIES: AutocompleteSuggestion[] = [
["Aarhus", "Denmark"], ["Amsterdam", "Netherlands"], ["Antwerp", "Belgium"], ["Athens", "Greece"],
["Barcelona", "Spain"], ["Berlin", "Germany"], ["Bilbao", "Spain"], ["Bordeaux", "France"],
["Bratislava", "Slovakia"], ["Brussels", "Belgium"], ["Budapest", "Hungary"], ["Copenhagen", "Denmark"],
["Dublin", "Ireland"], ["Frankfurt", "Germany"], ["Gdańsk", "Poland"], ["Genoa", "Italy"],
["Gothenburg", "Sweden"], ["Hamburg", "Germany"], ["Helsinki", "Finland"], ["Lisbon", "Portugal"],
["Ljubljana", "Slovenia"], ["Lyon", "France"], ["Madrid", "Spain"], ["Marseille", "France"],
["Milan", "Italy"], ["Naples", "Italy"], ["Oslo", "Norway"], ["Porto", "Portugal"],
["Rotterdam", "Netherlands"], ["Salzburg", "Austria"], ["Tallinn", "Estonia"], ["Valencia", "Spain"],
].map(([city, country]) => ({ value: city, label: city, description: country }))
// A long answer list: the panel stops at its own height and scrolls, the
// arrow keys carry the highlight down it and keep it in view, and Home and End
// jump to either end. A city Northwind doesn't ship from yet is still an answer.
export default function AutocompleteLongList() {
const id = React.useId()
const [value, setValue] = React.useState("")
return (
<div className="flex w-full max-w-sm flex-col gap-2 self-start">
<Label htmlFor={id}>Ship from</Label>
<Autocomplete
id={id}
value={value}
onValueChange={setValue}
getSuggestions={(query) => CITIES.filter((city) => city.label.toLowerCase().includes(query.toLowerCase()))}
debounce={100}
placeholder="Search cities"
/>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | What is in the field. Always controlled. |
| onValueChange | (value: string) => void | — | Every keystroke, and the suggestion's own value when one is taken. |
| getSuggestions | (query: string) => Promise<AutocompleteSuggestion[]> | AutocompleteSuggestion[] | — | Called with the debounced query once it is minLength long. Sync or async; write it inline, it is held in a ref. |
| AutocompleteSuggestion | { value: string; label: string; description?: string } | — | One offer: what the field becomes, what it reads as, and an optional second line. |
| onSelect | (suggestion: AutocompleteSuggestion) => void | — | Fires only when a suggestion is taken — typed text never reaches it. |
| minLength | number | 1 | How much has to be typed before the source is asked anything. |
| debounce | number | 200 | Milliseconds of quiet before the query is sent. |
| emptyText | string | "No matches" | Shown and announced when nothing matched. An empty string hides the panel instead. |
Dependencies
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useDebouncedValue } from "@/hooks/use-debounced-value"
import { Input } from "@/components/ui/input"
export type AutocompleteSuggestion = {
/** What the input is set to when this one is taken. */
value: string
label: string
/** A second line under the label — the country, the team, the id. */
description?: string
}
// `onSelect` is the suggestion that was taken, not the DOM select event, so it
// replaces the input prop of the same name rather than intersecting with it —
// the way TaskProgress replaces onCancel.
export type AutocompleteProps = Omit<
React.ComponentProps<"input">,
"onChange" | "value" | "onSelect"
> & {
value: string
onValueChange: (value: string) => void
/** Called with the debounced query once it is at least `minLength` long. May be async. */
getSuggestions: (
query: string
) => Promise<AutocompleteSuggestion[]> | AutocompleteSuggestion[]
/** Fires only when a suggestion is taken — typed text never reaches it. */
onSelect?: (suggestion: AutocompleteSuggestion) => void
minLength?: number
debounce?: number
/** Shown, and announced, when the query matched nothing. Empty string hides the panel. */
emptyText?: string
}
/**
* A text field that offers suggestions without insisting on them.
*
* The free-text half of the combobox pattern: whatever is typed is the value,
* the listbox is an offer, and `onSelect` fires only when one is taken — so a
* tag, an address or a search term that no source knows about is still a legal
* answer. `Combobox` is the other half, for a field whose value must be one of
* a known set.
*
* The query is debounced through `useDebouncedValue`, so a run of keystrokes
* asks the source once; a slow answer the reader has already typed past is
* dropped rather than replacing a newer one; and a source that rejects reads as
* no matches, because a listbox is not the place to report a failure — catch it
* inside `getSuggestions` and raise a notify.error from there.
*/
function Autocomplete({
className,
value,
onValueChange,
getSuggestions,
onSelect,
minLength = 1,
debounce = 200,
emptyText = "No matches",
onKeyDown,
onFocus,
onBlur,
...props
}: AutocompleteProps) {
const listId = React.useId()
const listRef = React.useRef<HTMLUListElement>(null)
const optionId = (index: number) => `${listId}-option-${index}`
const [open, setOpen] = React.useState(false)
const [activeIndex, setActiveIndex] = React.useState(-1)
// A stale answer is dropped by `cancelled` below — the effect that fetched it
// flips its own flag before a newer one can settle — not by comparing this
// against the current query, so it only ever holds the latest settled answer.
const [answer, setAnswer] = React.useState<{ query: string; items: AutocompleteSuggestion[] }>({
query: "",
items: [],
})
const query = useDebouncedValue(value, debounce)
const enabled = query.length >= minLength
// Callers write `getSuggestions` inline, so it is a new function every render.
// Held in a ref, it cannot re-run the fetch on renders it did not cause.
const sourceRef = React.useRef(getSuggestions)
React.useEffect(() => {
sourceRef.current = getSuggestions
})
React.useEffect(() => {
if (query.length < minLength) return
let cancelled = false
const settle = (items: AutocompleteSuggestion[]) => {
if (cancelled) return
setAnswer({ query, items })
// `value` is controlled, so it can change from outside the field's own
// onChange (the one place that already resets this) — a highlight left
// over from a longer list must not outlive the list that had it.
setActiveIndex((current) => (current >= items.length ? items.length - 1 : current))
}
Promise.resolve(sourceRef.current(query)).then(settle, () => settle([]))
return () => {
cancelled = true
}
}, [query, minLength])
const items = enabled ? answer.items : []
// The answer on show was given for the text as it was. Until one arrives for
// the text as it is — through the debounce and the round trip — the list is
// stale: it stays up, dimmed and busy, so the panel doesn't flicker shut on
// every keystroke, but nothing in it can be highlighted or taken.
const pending = value !== answer.query
const showEmpty = enabled && items.length === 0 && emptyText !== ""
const shown = open && (items.length > 0 || showEmpty)
const status = !shown
? ""
: items.length > 0
? `${items.length} suggestion${items.length === 1 ? "" : "s"}`
: emptyText
// Keeps the highlighted option in view inside a list taller than its panel.
React.useEffect(() => {
if (activeIndex < 0) return
const option = listRef.current?.children[activeIndex] as HTMLElement | undefined
option?.scrollIntoView?.({ block: "nearest" })
}, [activeIndex])
function take(suggestion: AutocompleteSuggestion) {
onValueChange(suggestion.value)
onSelect?.(suggestion)
setOpen(false)
setActiveIndex(-1)
}
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
onKeyDown?.(event)
if (event.defaultPrevented) return
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
if (items.length === 0) return
event.preventDefault()
setOpen(true)
if (pending) return
const delta = event.key === "ArrowDown" ? 1 : -1
setActiveIndex((current) => {
const next = current + delta
if (next < 0) return items.length - 1
if (next >= items.length) return 0
return next
})
return
}
if (event.key === "Home" || event.key === "End") {
if (items.length === 0 || pending) return
event.preventDefault()
setOpen(true)
setActiveIndex(event.key === "Home" ? 0 : items.length - 1)
return
}
if (event.key === "Enter") {
// Only a highlighted suggestion is Enter's business. With none, the key
// belongs to the form around the field.
const suggestion = shown && !pending && activeIndex >= 0 ? items[activeIndex] : undefined
if (suggestion) {
event.preventDefault()
take(suggestion)
}
return
}
if (event.key === "Escape" && shown) {
event.preventDefault()
setOpen(false)
setActiveIndex(-1)
}
}
return (
<div
data-slot="autocomplete"
data-open={shown || undefined}
className={cn("relative w-full", className)}
>
<Input
role="combobox"
aria-expanded={shown}
// Whichever panel is showing — the listbox or the empty message —
// carries this id, so an open combobox never controls an element that
// is not actually in the DOM.
aria-controls={shown ? listId : undefined}
aria-activedescendant={activeIndex >= 0 ? optionId(activeIndex) : undefined}
aria-autocomplete="list"
// The browser's own dropdown would sit on top of this one.
autoComplete="off"
value={value}
onChange={(event) => {
onValueChange(event.target.value)
setOpen(true)
setActiveIndex(-1)
}}
onKeyDown={handleKeyDown}
onFocus={(event) => {
onFocus?.(event)
setOpen(true)
}}
onBlur={(event) => {
onBlur?.(event)
setOpen(false)
setActiveIndex(-1)
}}
{...props}
/>
{shown ? (
<div
data-slot="autocomplete-panel"
className="absolute top-full end-0 start-0 z-50 mt-1 elev-1 p-1"
>
{items.length === 0 ? (
<div
id={listId}
data-slot="autocomplete-empty"
aria-busy={pending || undefined}
className="px-2 py-3 text-center text-sm text-muted-foreground transition-opacity duration-(--duration-fast) ease-(--ease-standard) aria-busy:opacity-60"
>
{emptyText}
</div>
) : null}
{items.length > 0 ? (
<ul
ref={listRef}
id={listId}
role="listbox"
data-slot="autocomplete-list"
aria-busy={pending || undefined}
className="max-h-56 overflow-y-auto overscroll-contain transition-opacity duration-(--duration-fast) ease-(--ease-standard) aria-busy:opacity-60"
>
{items.map((item, index) => (
<li
key={item.value}
id={optionId(index)}
role="option"
aria-selected={index === activeIndex}
data-slot="autocomplete-option"
data-active={index === activeIndex || undefined}
// Taking a suggestion must not blur the field first, or the
// panel closes out from under the pointer before the click
// lands.
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
if (!pending) take(item)
}}
onMouseEnter={() => {
if (!pending) setActiveIndex(index)
}}
className="flex cursor-default flex-col rounded-md px-2 py-1.5 text-sm data-active:bg-accent data-active:text-accent-foreground"
>
<span className="truncate">{item.label}</span>
{item.description ? (
<span className="truncate text-xs text-muted-foreground">
{item.description}
</span>
) : null}
</li>
))}
</ul>
) : null}
</div>
) : null}
{/* One region, so the count is re-announced as it changes rather than
each option being read out on the way past. */}
<span role="status" aria-live="polite" className="sr-only">
{status}
</span>
</div>
)
}
export { Autocomplete }