# Listbox

> A React listbox: a searchable list of options for single or multiple choice, with groups, async loading, create-new and virtualized rows.

- Type: React component (Inputs)
- Access: Free (the source is open to everyone)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add listbox`
- Docs: https://wingo-ui.com/components/listbox

## Interaction

Click a row to pick it: in single mode a check draws itself at the end of the row, in multiple mode the leading checkbox ticks and a second click unticks it. Moving the mouse over the list highlights the row under the pointer; the highlight only shows while the list or its search has focus or the pointer is over it, so a resting list stays calm. With search on, typing filters as you go (case and accents never matter, so espanola finds Española, and state codes like OR work as keywords), matched letters turn bold, groups without matches disappear and the clear button empties the query. Keyboard: Tab reaches the list (or its search field); ArrowDown and ArrowUp move the highlight and wrap around, PageUp and PageDown jump a screen, Home and End go to the first and last row when the list itself has focus, Enter picks the highlighted row, Space toggles it in multiple mode, Ctrl+A (Cmd+A on a Mac) selects every visible option in multiple mode, and typing letters on the list jumps to the option that starts with them (the same letter again cycles). Select all toggles every visible enabled option; with a maximum, the other rows dim once you reach it. With creatable, a query that matches nothing adds a Create row that turns into the new option (with a spinner while an async create runs). Async lists keep their previous results while a new search loads, show a spinner in the search row, and offer Try again after an error. Lists above the virtual threshold only render the rows in view, so thousands of options scroll at 60fps. On a phone rows grow to 44px (48px in sheet density with a pressed state), scrolling with a finger never picks a row, the search field uses 16px text so iOS never zooms, and with reduced motion the check appears without drawing.

## Installation

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

The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add https://wingo-ui.com/r/listbox.json`

Files:

- `components/ui/listbox.tsx`

npm dependencies: `npm i @tanstack/react-virtual motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Checkbox](https://wingo-ui.com/components/checkbox) ([Markdown](https://wingo-ui.com/components/checkbox.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [useDebounce](https://wingo-ui.com/components/use-debounce) ([Markdown](https://wingo-ui.com/components/use-debounce.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))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.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 { Listbox, type ListboxItems } from "@/components/ui/listbox"

const STATES: ListboxItems = [
  { value: "CA", label: "California", keywords: ["CA", "Sacramento"] },
  { value: "NY", label: "New York", keywords: ["NY", "Albany"] },
  { value: "OR", label: "Oregon", keywords: ["OR", "Salem"] },
  { value: "TX", label: "Texas", keywords: ["TX", "Austin"] },
]

export function ShippingState() {
  const [state, setState] = useState("OR")
  return (
    <Listbox
      label="Pick a state"
      search
      searchPlaceholder="Search a state or its code…"
      emptyText="No states found"
      name="state"
      options={STATES}
      value={state}
      onValueChange={setState}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options` | `ListboxItems` |  | Options and groups: { value, label, description?, icon?, badge?, disabled?, keywords?, textValue? } or { label?, items: ListboxOption[] }. Loose options between groups form their own unlabeled section. Values must be unique (a dev warning names duplicates; the first wins); a non-string label needs textValue for search and typeahead. |
| `value?` | `string \| string[]` |  | The selected value while controlled: a string ("" = none), or string[] with multiple. A value that is not among the options is kept and labeled from every option seen before. |
| `defaultValue?` | `string \| string[]` |  | The starting value while uncontrolled; "" or [] by default. A form reset returns to it. |
| `onValueChange?` | `(value: string \| string[]) => void` |  | Called with the next value: a string in single mode, the string[] (in pick order) with multiple. |
| `multiple?` | `boolean` | `false` | Many values: rows get a leading checkbox and toggle; aria-multiselectable is set. Single mode keeps the picked value when you click it again. |
| `search?` | `boolean` | `false` | The search row on top. Focus then lives in the search field (role="combobox") and drives the list with aria-activedescendant; without it the list itself is focusable. |
| `searchPlaceholder?` | `string` | `"Search…"` | Placeholder of the search field. |
| `emptyText?` | `string` | `"No results"` | Shown when nothing matches the query (or the list is empty). |
| `loadingText?` | `string` | `"Loading…"` | Shown with a spinner on the first load, while there are no rows yet. Later loads keep the rows and spin in the search row instead. |
| `errorText?` | `string` | `"Could not load results"` | Shown with a Try again button when loadOptions rejects; the last results stay. In the playground a custom text previews the error state. |
| `loading?` | `boolean` | `false` | External loading, for options you fetch yourself: with no options it shows loadingText, with options the search row spins. The playground clears the options while it is on. |
| `loadOptions?` | `(query: string, context: { signal: AbortSignal }) => Promise<ListboxItems>` |  | Async options for the debounced query. The previous request is aborted, the last 20 queries are cached so going back is instant, the previous results stay visible while loading, and an error keeps them and shows errorText with Try again. Results are not filtered again. |
| `debounce?` | `number` | `250` | ms to wait after typing before loadOptions runs. The first load runs at once. |
| `minQueryLength?` | `number` | `0` | loadOptions waits for this many characters; below it the static options show. |
| `filter?` | `boolean \| ((option: ListboxOption, query: string) => number \| null)` | `true` | true: text-match on the text and keywords (every word must match, best first inside each group). false: the options are already filtered (server search). A function returns a score, higher first, or null to hide. |
| `fuzzy?` | `boolean` | `false` | Letters may be spread out (orgn finds Oregon). Off by default so results stay predictable. |
| `query?` | `string` |  | The search text while controlled. Pair it with onQueryChange; defaultQuery starts an uncontrolled one. |
| `defaultQuery?` | `string` | `""` | The starting search text while uncontrolled. The list opens filtered (with a search row the text shows in it). |
| `onQueryChange?` | `(query: string) => void` |  | Called as the search text changes. |
| `creatable?` | `boolean` | `false` | When the trimmed query matches no option exactly (diacritics folded), a Create row appears last (first and highlighted when nothing else matches). Without onCreate the query itself is selected as a new value. In the playground new counties are added to the list. |
| `onCreate?` | `(query: string) => (string \| void \| Promise<string \| void>)` |  | Called from the Create row. A returned string (or a promise of one) is selected; a promise shows a spinner on the row until it settles. |
| `createLabel?` | `(query: string) => ReactNode` | `` "q => `Create \"${q}\"`" `` | The Create row's text. |
| `maxSelected?` | `number` | `0` | Multiple only: at the maximum the other rows turn aria-disabled and dim; lowering it keeps the current selection and blocks additions. 0 = no limit. |
| `selectAll?` | `boolean` | `false` | Multiple only: a Select all row on top that shows a dash while some are picked. It toggles every visible enabled option, up to maxSelected (at the maximum it clears them). The playground turns multiple on with it. |
| `selectAllLabel?` | `string` | `"Select all"` | The Select all row's text. |
| `highlightMatches?` | `boolean` | `true` | Matched letters of the label turn semibold while you search. |
| `indicator?` | `"auto" \| "check" \| "checkbox" \| "none"` | `"auto"` | auto: a trailing check in single mode, a leading checkbox with multiple. none tints selected rows in the tone instead. |
| `density?` | `"menu" \| "sheet"` | `"menu"` | menu: 36px rows (44px on touch screens). sheet: 48px rows with 15px text and a pressed state instead of a hover highlight, as pickers use in bottom sheets. Long labels truncate in menu and wrap in sheet. |
| `variant?` | `"panel" \| "plain"` | `"panel"` | panel: a rounded-2xl surface with a raised shadow and a focus ring. plain: no surface, for a list inside your own card or sheet. |
| `maxHeight?` | `number` | `320` | px before the list scrolls; group labels stick to the top while their group scrolls. 0 grows with the content. |
| `virtualThreshold?` | `number` | `100` | Above this many visible options only the rows in view render (@tanstack/react-virtual, measured heights, aria-setsize and aria-posinset for screen readers). The playground has 42 counties, so 40 or less virtualizes it. |
| `autoHighlight?` | `boolean` | `true` | Highlight the selected option (or the first match while searching) whenever the query or the options change, so Enter picks at once. |
| `loop?` | `boolean` | `true` | ArrowDown on the last row wraps to the first, ArrowUp on the first to the last. |
| `typeahead?` | `boolean` | `true` | Letters typed while the list itself has focus jump to the option that starts with them (500ms buffer, the same letter again cycles). |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The check, the checkboxes and the tinted rows of indicator none. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone. Leave empty to use the tone. |
| `label?` | `ReactNode` | `""` | Renders the field anatomy around the list and names it (aria-labelledby). Use aria-label when there is no visible label. |
| `description?` | `ReactNode` | `""` | Helper text under the list, linked with aria-describedby. |
| `error?` | `ReactNode` | `""` | Replaces the description with the animated error and marks the list invalid (a red ring on the panel, aria-invalid). |
| `required?` | `boolean` | `false` | The red mark after the label and aria-required on the list. |
| `invalid?` | `boolean` | `false` | The invalid look and aria-invalid without a message. |
| `disabled?` | `boolean` | `false` | Dims the list to 50% and blocks picking, searching and focus. Single options take disabled on their own. |
| `name?` | `string` | `""` | Form field name: one hidden input per selected value (FormData.getAll(name) with multiple). A form reset returns to defaultValue. |
| `aria-label?` | `string` | `""` | The list's accessible name when there is no visible label. |
| `onSelect?` | `(option: ListboxOption) => void` |  | Called after every pick with the option (pickers close here in single mode). |
| `renderOption?` | `(option, state: { selected, highlighted, disabled, ranges }) => ReactNode` |  | Replaces a row's content; the row element, its aria, its classes and the indicator stay. |
| `labels?` | `Partial<{ results: (count: number) => string; clearSearch: string; retry: string }>` |  | The polite result count announced 500ms after the list settles ("12 results"), the clear button's name ("Clear search") and the retry button ("Try again"). |
| `fieldProps?` | `Partial<FieldProps>` |  | Anything else for the field wrapper: optional, labelAction, orientation, hideLabel. |
| `classNames?` | `Partial<Record<"search" \| "list" \| "option" \| "icon" \| "label" \| "description" \| "badge" \| "check" \| "group" \| "status" \| "create" \| "field" \| "error", string>>` |  | Extra classes for the parts. label and description target the option's text; the field's own label keeps its recipe. |

## Parts

### useListbox (hook)

The engine behind every picker: filtering, async with abort and cache, highlight, keys, typeahead, selection, create and virtualization. Returns rows, value, selectedOptions, select / toggle / clear / selectAll, highlight / move, query / setQuery, loading / error / retry, onKeyDown (true when handled), scrollRef, and the prop getters getListProps({ focusable }), getOptionProps(row), getInputProps({ expanded }). Signature: (options: UseListboxOptions) => ListboxState.

### ListboxProvider (component)

Shares a useListbox state with ListboxSearch, ListboxList and ListboxStatus. Props: { value: ListboxState; children: ReactNode }.

### ListboxSearch (component)

The 44px search row with an icon, a borderless 16px-on-phones input (getInputProps), a spinner while loading and a clear button. Rest props go to the input. Props: InputProps & { placeholder?; clearLabel?; loadingLabel?; classNames? }.

### ListboxList (component)

The rows, groups, status and announcements. scroll="parent" leaves scrolling to a DrawerBody that received scrollRef (sheets); focusable makes the list the keyboard target. Props: { density?; indicator?; highlightMatches?; maxHeight?; emptyText?; loadingText?; errorText?; retryLabel?; createLabel?; selectAllLabel?; renderOption?; focusable?; scroll?: "self" | "parent"; tone?; color?; labels?; classNames? }.

### ListboxStatus (component)

The empty / loading / error block alone. Props: { status?: "empty" | "loading" | "error"; emptyText?; loadingText?; errorText?; retryLabel?; onRetry? }.

### LISTBOX_ROW (constant)

The row classes per density, for custom rows that match. Type: Record<ListboxDensity, { base; description }>.

### flattenOptions (function)

Every option of a list, with groups flattened. Signature: (items) => ListboxOption[].

### optionText (function)

An option's plain text: label, else textValue, else value. Signature: (option) => string.

## Examples

- **Multiple with select all and a maximum**: multiple, selectAll and maxSelected={3}: leading checkboxes, and the rest disable once three are picked.
- **Groups, icons, descriptions and counts**: Option groups with sticky labels, a second line, en-US counts as badges and one disabled row. Search keeps the groups.
- **Virtualized cities**: Over 300 cities with virtualThreshold={50}: only the visible rows render. Type espanola to find Española.
- **Async search with errors**: loadOptions against a 500ms fake API with Avatar icons; previous results stay while typing. Turn on the switch to see the error and retry.
- **Creatable tags**: creatable with an async onCreate: the create row shows a spinner, then the new tag is selected.
- **Sheet density**: density="sheet" and variant="plain" in a phone-width sheet: 48px rows, a pressed state on touch, as the pickers show it.
- **Headless swatches**: useListbox driving a custom grid: the engine keeps the selection, highlight, typeahead and aria; the grid only draws.

## See also

- [Checkbox](https://wingo-ui.com/components/checkbox) ([Markdown](https://wingo-ui.com/components/checkbox.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [useDebounce](https://wingo-ui.com/components/use-debounce) ([Markdown](https://wingo-ui.com/components/use-debounce.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [Command Menu](https://wingo-ui.com/components/command-menu) ([Markdown](https://wingo-ui.com/components/command-menu.md))

---

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