Skip to contentVibraUI
Inputs & filters

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

Needs the @vibra registry in your components.json — set it up once.

Examples

From a search endpoint

Two letters start the search, a round trip answers it, and a run of keystrokes asks once.

Search field

Recent searches before anything is typed, a clear button once something is, and Enter searches the words as typed.

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.

Long list

The panel stops at its own height and scrolls, the highlight stays in view, and Home and End reach either end.

Props

PropTypeDefaultDescription
valuestring—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.
minLengthnumber1How much has to be typed before the source is asked anything.
debouncenumber200Milliseconds of quiet before the query is sent.
emptyTextstring"No matches"Shown and announced when nothing matched. An empty string hides the panel instead.

Dependencies

Source

components/ui/autocomplete.tsx
"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 }