HextaUI

Combobox: Async search

Turn off built-in filtering with filter={null}, fetch on onInputValueChange, and show progress in <ComboboxStatus />, which announces it to screen readers. The popup height animates as results change.

"use client"

import * as React from "react"
import { IconLoader2 } from "@tabler/icons-react"

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxStatus,
} from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"

const countries = [
  { value: "ar", label: "Argentina" },
  { value: "au", label: "Australia" },
  { value: "br", label: "Brazil" },
  { value: "ca", label: "Canada" },
  { value: "de", label: "Germany" },
  { value: "fr", label: "France" },
  { value: "in", label: "India" },
  { value: "jp", label: "Japan" },
  { value: "mx", label: "Mexico" },
  { value: "nl", label: "Netherlands" },
  { value: "es", label: "Spain" },
  { value: "se", label: "Sweden" },
  { value: "ch", label: "Switzerland" },
  { value: "za", label: "South Africa" },
  { value: "kr", label: "South Korea" },
  { value: "uk", label: "United Kingdom" },
  { value: "us", label: "United States" },
]

export function ComboboxAsync() {
  const [query, setQuery] = React.useState("")
  const [results, setResults] = React.useState(countries.slice(0, 5))
  const [loading, setLoading] = React.useState(false)
  const runRef = React.useRef(0)

  React.useEffect(() => {
    const run = ++runRef.current
    const timer = setTimeout(() => {
      setLoading(true)
      setTimeout(() => {
        if (run !== runRef.current) {
          return
        }
        const needle = query.trim().toLowerCase()
        setResults(
          countries.filter((country) =>
            country.label.toLowerCase().includes(needle)
          )
        )
        setLoading(false)
      }, 600)
    }, 150)
    return () => {
      clearTimeout(timer)
      runRef.current += 1
    }
  }, [query])

  return (
    <div className="flex w-full max-w-xs flex-col gap-2">
      <Label htmlFor="combobox-async">Country</Label>
      <Combobox
        items={results}
        filter={null}
        inputValue={query}
        onInputValueChange={setQuery}
      >
        <ComboboxInput id="combobox-async" placeholder="Search countries" />
        <ComboboxContent>
          <ComboboxStatus>
            {loading ? (
              <>
                <IconLoader2 className="animate-spin" />
                Searching…
              </>
            ) : null}
          </ComboboxStatus>
          {loading ? null : (
            <ComboboxEmpty>No country matches “{query}”.</ComboboxEmpty>
          )}
          <ComboboxList>
            {(item: (typeof countries)[number]) => (
              <ComboboxItem key={item.value} value={item}>
                {item.label}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}

This adds the example as components/examples/combobox/async.tsx, together with the Combobox component and the HextaUI theme tokens it needs.

pnpm dlx shadcn@latest add https://hextaui.com/r/combobox-async.json

To add only the component, follow the Combobox installation.

Props in this example

The Combobox props this example sets, from the API reference.

PropTypeDefault
itemsThe options. Filtered as you type and passed to the list’s render function.
Item[] | Group[]–
inputValue
string–
onInputValueChange
(inputValue: string, details) => void–
filterCustom matching. null turns filtering off for server-side search.
((item, query, itemToString) => boolean) | null–
PropTypeDefault
placeholder
string–
PropTypeDefault
valueThe item this row represents.
Item–

Every prop is in the Combobox API reference.

See them all, with keyboard support and accessibility notes, on the Combobox page.