# Combobox

> 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: React component (Inputs)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add combobox`
- Docs: https://wingo-ui.com/components/combobox

## Interaction

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.

## Installation

```bash
npx wingo-ui@latest add combobox
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/combobox` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/combobox.tsx`

npm dependencies: `npm i @radix-ui/react-popover motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label?` | `ReactNode` | `""` | The label above the field; renders the field anatomy and names the list and the phone sheet. |
| `description?` | `ReactNode` | `""` | Helper text under the field, linked with aria-describedby. |
| `error?` | `ReactNode` | `""` | Replaces the description with an animated error and marks the field invalid. |
| `required?` | `boolean` | `false` | A red * after the label and required on the input. |
| `invalid?` | `boolean` | `false` | The invalid look and aria-invalid without a message. |
| `disabled?` | `boolean` | `false` | Dims the field and blocks typing and opening. |
| `readOnly?` | `boolean` | `false` | The text stays visible and focusable; nothing opens and nothing can be typed. |
| `options?` | `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. |
| `debounce?` | `number` | `250` | Milliseconds of quiet before loadOptions runs. |
| `minQueryLength?` | `number` | `0` | loadOptions waits for this many characters. |
| `filter?` | `boolean, or (option: ComboboxOption, query: string) => score (number, null hides)` | `true` | 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). |
| `fuzzy?` | `boolean` | `false` | Letters in order may have gaps ("sfco" finds San Francisco). |
| `value?` | `string` |  | The 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. |
| `defaultValue?` | `string` | `""` | The starting value while uncontrolled. A form reset returns to it. |
| `onValueChange?` | `(value: string) => void` |  | Called with the new value; "" when it clears. |
| `inputValue?` | `string` |  | The text in the field, controlled. |
| `defaultInputValue?` | `string` | `""` | The starting text while uncontrolled; it filters the list like typed text. |
| `onChange?` | `(event: ChangeEvent<HTMLInputElement>) => void` |  | The native change event of every keystroke, untouched (react-hook-form's register works). |
| `onInputValueChange?` | `(text: string) => void` |  | Called on every keystroke and when the text snaps to an option. |
| `allowCustomValue?` | `boolean` | `false` | The 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. |
| `creatable?` | `boolean` | `false` | A 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. |
| `createLabel?` | `(query: string) => ReactNode` | `` "(q) => `Create \"${q}\"`" `` | The create row's text. |
| `placeholder?` | `string` | `""` | Shown while the field is empty, also in the phone sheet's search box. |
| `emptyText?` | `string` | `"No results"` | Shown when the query matches nothing. |
| `loadingText?` | `string` | `"Loading…"` | The first load's status row and the spinner's accessible name. |
| `errorText?` | `string` | `"Could not load results"` | Shown when loadOptions fails, above a retry button. |
| `retryLabel?` | `string` | `"Try again"` | The retry button under errorText. |
| `clearLabel?` | `string` | `"Clear"` | The accessible name of the x button. |
| `toggleLabel?` | `string` | `"Show suggestions"` | The accessible name of the chevron button. |
| `highlightMatches?` | `boolean` | `true` | The matched letters of each option in bold. |
| `openOnFocus?` | `boolean` | `false` | Opens with every option (or the recent picks you pass) as soon as the field gets focus. |
| `autoHighlight?` | `boolean` | `true` | The first match is highlighted, so Enter picks it. Turn it off for free text, where Enter should keep what you typed. |
| `clearable?` | `boolean` | `true` | An x while there is text or a value; Escape on a closed list clears too. |
| `leftIcon?` | `ReactNode` |  | An icon before the text, like a MapPin. Unsized svgs follow the size. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | 32 / 40 / 48px like every field; md grows to 44px on touch screens. |
| `variant?` | `"soft" \| "outline" \| "underline" \| "ghost"` | `"soft"` | The box: a filled well, an outline, a bottom line, or nothing until hover. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Corner shape, scaled per size like the button (md is 8 / 10 / 12px); also the sheet's search box. |
| `defaultOpen?` | `boolean` | `false` | Starts with the list (or the sheet) open. Pass open and onOpenChange to control it. |
| `open?` | `boolean` |  | Whether the list is open, controlled. It shows only when there is something to show, and never while disabled or readOnly. |
| `onOpenChange?` | `(open: boolean) => void` |  | Called when the list opens or closes. |
| `responsive?` | `boolean` | `true` | Below 768px the field opens a full-height search sheet instead of the popover. |
| `sheetTitle?` | `string` | `"Search"` | The phone sheet's title for screen readers (its visible header is the search box); a string label wins. |
| `cancelLabel?` | `string` | `"Cancel"` | The text button next to the sheet's search box. |
| `side?` | `"top" \| "right" \| "bottom" \| "left"` | `"bottom"` | Where the list opens; it flips when there is no room. |
| `align?` | `"start" \| "center" \| "end"` | `"start"` | How the list lines up with the field. |
| `maxHeight?` | `number` | `360` | The list's height limit in px, capped by the room on screen. |
| `virtualThreshold?` | `number` | `100` | Above this many rows only the visible ones render. |
| `renderOption?` | `(option, state: { selected, highlighted, disabled, ranges }) => ReactNode` |  | Replaces a row's content; the row element, its aria and its classes stay. |
| `name?` | `string` | `""` | Submits the value through a hidden input. |
| `id?` | `string` |  | The input's id; generated when left out. |
| `container?` | `HTMLElement \| null` |  | Portal target for the list and the sheet (default document.body). |
| `fieldProps?` | `Partial<FieldProps>` |  | Anything else for the field wrapper: optional, labelAction, orientation, labelWidth, hideLabel. |
| `classNames?` | `Partial<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. |
| `className?` | `string` |  | Extra classes for the outermost element: the Field when there is a label or message, else the combobox root. |
| `ref?` | `Ref<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.

## See also

- [Select](https://wingo-ui.com/components/select) ([Markdown](https://wingo-ui.com/components/select.md))
- [Multi Select](https://wingo-ui.com/components/multi-select) ([Markdown](https://wingo-ui.com/components/multi-select.md))
- [Tag Input](https://wingo-ui.com/components/tag-input) ([Markdown](https://wingo-ui.com/components/tag-input.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Search Input](https://wingo-ui.com/components/search-input) ([Markdown](https://wingo-ui.com/components/search-input.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))

---

Canonical: https://wingo-ui.com/components/combobox
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
