Combobox React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React combobox and autocomplete: type to filter a list or search a server, with matches in bold, a create row and a phone search sheet.
Type and the list opens under the field with the matching letters in bold and the best matches first ("st louis" finds Saint Louis, diacritics never matter); with loadOptions the query goes to your server after a short pause, a spinner shows in the field while the previous results stay, and an old answer never overwrites a newer one. Click an option (or press Enter on the highlighted one) to fill the field and close the list. Backspace edits the chosen text and the value clears once the text no longer spells it. When you leave the field the text goes back to the chosen option, or empties; with allowCustomValue whatever you typed becomes the value. With creatable a "Create" row appears when nothing matches exactly. The chevron toggles the list and the x clears it. Keyboard: ArrowDown opens the list (every option when the field is empty) and moves down, ArrowUp moves up, Alt+ArrowDown opens without moving, PageUp / PageDown jump, Enter picks, Escape closes the list and a second Escape clears the field, Home / End move the caret, Tab closes and keeps the rule above. With openOnFocus the list opens as soon as the field gets focus. On a phone the field never raises the keyboard: a tap opens a full-height sheet with a search box (the keyboard comes up at once) and Cancel, big 48px rows, and a tap on a result fills the field and closes the sheet; the keyboard's search key picks the first result. name submits the value through a hidden input.
Combobox is a Pro React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add combobox, together with Drawer, Field, Listbox, Spinner, Text match and 4 more and the npm packages @radix-ui/react-popover, motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add comboboxCombobox is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import Combobox from @/components/ui/combobox.
import { useState } from "react"
import { MapPin } from "lucide-react"
import { Combobox, type ComboboxItems } from "@/components/ui/combobox"
async function searchCities(query: string, { signal }: { signal: AbortSignal }): Promise<ComboboxItems> {
const response = await fetch(`/api/cities?q=${encodeURIComponent(query)}`, { signal })
const cities: { id: string; name: string; state: string }[] = await response.json()
return cities.map((city) => ({ value: city.id, label: city.name, description: city.state }))
}
export function CityField() {
const [city, setCity] = useState("")
return (
<Combobox
label="City"
placeholder="Search for a city"
leftIcon={<MapPin />}
loadOptions={searchCities}
minQueryLength={1}
value={city}
onValueChange={setCity}
emptyText="No cities found"
errorText="Could not load cities"
sheetTitle="Search cities"
name="city"
/>
)
}Props
labeldefault""ReactNodeThe label above the field; renders the field anatomy and names the list and the phone sheet.
descriptiondefault""ReactNodeHelper text under the field, linked with aria-describedby.
errordefault""ReactNodeReplaces the description with an animated error and marks the field invalid.
requireddefaultfalsebooleanA red * after the label and required on the input.
invaliddefaultfalsebooleanThe invalid look and aria-invalid without a message.
disableddefaultfalsebooleanDims the field and blocks typing and opening.
readOnlydefaultfalsebooleanThe text stays visible and focusable; nothing opens and nothing can be typed.
optionsdefault"[]"ComboboxItems ((ComboboxOption | { label?: ReactNode; items: ComboboxOption[] })[])Options and groups, filtered locally as you type. An option is { value, label, description?, icon?, badge?, disabled?, keywords?, textValue? }. With loadOptions they show while the query is shorter than minQueryLength.
loadOptions(query: string, context: { signal: AbortSignal }) => Promise<ComboboxItems>Server search for the debounced query. The results replace the list, the previous ones stay while loading, a newer query aborts the older request, and the last 20 answers are cached.
debouncedefault250numberMilliseconds of quiet before loadOptions runs.
minQueryLengthdefault0numberloadOptions waits for this many characters.
filterdefaulttrueboolean, or (option: ComboboxOption, query: string) => score (number, null hides)true filters with text-match on the label and keywords; false means the options are already filtered; a function scores each option (null hides it).
fuzzydefaultfalsebooleanLetters in order may have gaps ("sfco" finds San Francisco).
valuestringThe chosen option's value (or the free text with allowCustomValue), controlled. A value that is not in the current results shows the cached option text, else the value.
defaultValuedefault""stringThe starting value while uncontrolled. A form reset returns to it.
onValueChange(value: string) => voidCalled with the new value; "" when it clears.
inputValuestringThe text in the field, controlled.
defaultInputValuedefault""stringThe starting text while uncontrolled; it filters the list like typed text.
onChange(event: ChangeEvent<HTMLInputElement>) => voidThe native change event of every keystroke, untouched (react-hook-form's register works).
onInputValueChange(text: string) => voidCalled on every keystroke and when the text snaps to an option.
allowCustomValuedefaultfalsebooleanThe typed text is a valid value (a street, a free label): it becomes the value on blur or Enter. Otherwise the text snaps back to the chosen option.
creatabledefaultfalsebooleanA create row when nothing matches exactly (first and highlighted when there are no results).
onCreate(query: string) => (string | void | Promise<string | void>)Runs for the create row; a returned value (or a promise of one) is selected, and a promise shows a spinner on the row. Without it the typed text becomes the value.
createLabeldefault"(q) => `Create "${q}"`"(query: string) => ReactNodeThe create row's text.
placeholderdefault""stringShown while the field is empty, also in the phone sheet's search box.
emptyTextdefault"No results"stringShown when the query matches nothing.
loadingTextdefault"Loading…"stringThe first load's status row and the spinner's accessible name.
errorTextdefault"Could not load results"stringShown when loadOptions fails, above a retry button.
retryLabeldefault"Try again"stringThe retry button under errorText.
clearLabeldefault"Clear"stringThe accessible name of the x button.
toggleLabeldefault"Show suggestions"stringThe accessible name of the chevron button.
highlightMatchesdefaulttruebooleanThe matched letters of each option in bold.
openOnFocusdefaultfalsebooleanOpens with every option (or the recent picks you pass) as soon as the field gets focus.
autoHighlightdefaulttruebooleanThe first match is highlighted, so Enter picks it. Turn it off for free text, where Enter should keep what you typed.
clearabledefaulttruebooleanAn x while there is text or a value; Escape on a closed list clears too.
leftIconReactNodeAn icon before the text, like a MapPin. Unsized svgs follow the size.
sizedefault"md""sm" | "md" | "lg"32 / 40 / 48px like every field; md grows to 44px on touch screens.
variantdefault"soft""soft" | "outline" | "underline" | "ghost"The box: a filled well, an outline, a bottom line, or nothing until hover.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner shape, scaled per size like the button (md is 8 / 10 / 12px); also the sheet's search box.
defaultOpendefaultfalsebooleanStarts with the list (or the sheet) open. Pass open and onOpenChange to control it.
openbooleanWhether the list is open, controlled. It shows only when there is something to show, and never while disabled or readOnly.
onOpenChange(open: boolean) => voidCalled when the list opens or closes.
responsivedefaulttruebooleanBelow 768px the field opens a full-height search sheet instead of the popover.
sheetTitledefault"Search"stringThe phone sheet's title for screen readers (its visible header is the search box); a string label wins.
cancelLabeldefault"Cancel"stringThe text button next to the sheet's search box.
sidedefault"bottom""top" | "right" | "bottom" | "left"Where the list opens; it flips when there is no room.
aligndefault"start""start" | "center" | "end"How the list lines up with the field.
maxHeightdefault360numberThe list's height limit in px, capped by the room on screen.
virtualThresholddefault100numberAbove this many rows only the visible ones render.
renderOption(option, state: { selected, highlighted, disabled, ranges }) => ReactNodeReplaces a row's content; the row element, its aria and its classes stay.
namedefault""stringSubmits the value through a hidden input.
idstringThe input's id; generated when left out.
containerHTMLElement | nullPortal target for the list and the sheet (default document.body).
fieldPropsPartial<FieldProps>Anything else for the field wrapper: optional, labelAction, orientation, labelWidth, hideLabel.
classNamesPartial<Record<"field" | "label" | "description" | "error" | "counter" | "root" | "box" | "input" | "leftIcon" | "spinner" | "clear" | "toggle" | "content" | "list" | "item" | "group" | "status" | "sheet" | "sheetSearch" | "cancel", string>>Extra classes for the inner parts. content is the popover, sheet the phone sheet.
classNamestringExtra classes for the outermost element: the Field when there is a label or message, else the combobox root.
refRef<HTMLInputElement> (a useRef object or an (el) => void callback)The text input. Other props (onBlur, onKeyDown, autoFocus, aria-*, data-*) land on it too; onChange gets the native event.
Examples
Groups
Sync options in two groups; the list opens on focus.
Creatable
A create row adds the missing brand and selects it.
Free text
allowCustomValue: suggestions help, any street is accepted.
Failing API
The error row with a retry button; the last results stay.
Recent picks
openOnFocus shows recent searches before anything is typed.
Sizes and variants
32 / 40 / 48px boxes with a leading MapPin, soft, outline and underline.
Dependencies
Files
- components/ui/combobox.tsx
FAQ
Is the React Combobox component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Combobox?
Run npx wingo-ui@latest add combobox. It also adds the Wingo UI items it builds on (Drawer, Field, Listbox, Spinner, Text match and 4 more) and the npm packages @radix-ui/react-popover, motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/combobox.
How does Combobox behave on phones?
Below 768px the field opens a full-height search sheet instead of the popover. The responsive prop controls this and is on by default.
See also
Used in
More React input components
Articles using this component
- React Combobox: Async Search Without Race Conditions
Component guides10 min read
- React Multi Select: Search, Chips, Select All and Mobile
Component guides10 min read