Listbox React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React listbox: a searchable list of options for single or multiple choice, with groups, async loading, create-new and virtualized rows.
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.
Listbox is a free 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 listbox, together with Field, Checkbox, Spinner, Button, Text match and 6 more and the npm packages @tanstack/react-virtual, 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 listboxUsage
Import Listbox from @/components/ui/listbox.
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
options*ListboxItemsOptions 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.
valuestring | 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.
defaultValuestring | string[]The starting value while uncontrolled; "" or [] by default. A form reset returns to it.
onValueChange(value: string | string[]) => voidCalled with the next value: a string in single mode, the string[] (in pick order) with multiple.
multipledefaultfalsebooleanMany values: rows get a leading checkbox and toggle; aria-multiselectable is set. Single mode keeps the picked value when you click it again.
searchdefaultfalsebooleanThe 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.
searchPlaceholderdefault"Search…"stringPlaceholder of the search field.
emptyTextdefault"No results"stringShown when nothing matches the query (or the list is empty).
loadingTextdefault"Loading…"stringShown 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.
errorTextdefault"Could not load results"stringShown with a Try again button when loadOptions rejects; the last results stay. In the playground a custom text previews the error state.
loadingdefaultfalsebooleanExternal 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.
debouncedefault250numberms to wait after typing before loadOptions runs. The first load runs at once.
minQueryLengthdefault0numberloadOptions waits for this many characters; below it the static options show.
filterdefaulttrueboolean | ((option: ListboxOption, query: string) => number | null)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.
fuzzydefaultfalsebooleanLetters may be spread out (orgn finds Oregon). Off by default so results stay predictable.
querystringThe search text while controlled. Pair it with onQueryChange; defaultQuery starts an uncontrolled one.
defaultQuerydefault""stringThe starting search text while uncontrolled. The list opens filtered (with a search row the text shows in it).
onQueryChange(query: string) => voidCalled as the search text changes.
creatabledefaultfalsebooleanWhen 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.
createLabeldefault"q => `Create "${q}"`"(query: string) => ReactNodeThe Create row's text.
maxSelecteddefault0numberMultiple only: at the maximum the other rows turn aria-disabled and dim; lowering it keeps the current selection and blocks additions. 0 = no limit.
selectAlldefaultfalsebooleanMultiple 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.
selectAllLabeldefault"Select all"stringThe Select all row's text.
highlightMatchesdefaulttruebooleanMatched letters of the label turn semibold while you search.
indicatordefault"auto""auto" | "check" | "checkbox" | "none"auto: a trailing check in single mode, a leading checkbox with multiple. none tints selected rows in the tone instead.
densitydefault"menu""menu" | "sheet"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.
variantdefault"panel""panel" | "plain"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.
maxHeightdefault320numberpx before the list scrolls; group labels stick to the top while their group scrolls. 0 grows with the content.
virtualThresholddefault100numberAbove 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.
autoHighlightdefaulttruebooleanHighlight the selected option (or the first match while searching) whenever the query or the options change, so Enter picks at once.
loopdefaulttruebooleanArrowDown on the last row wraps to the first, ArrowUp on the first to the last.
typeaheaddefaulttruebooleanLetters typed while the list itself has focus jump to the option that starts with them (500ms buffer, the same letter again cycles).
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The check, the checkboxes and the tinted rows of indicator none.
colordefault""stringAny CSS color; replaces the tone. Leave empty to use the tone.
labeldefault""ReactNodeRenders the field anatomy around the list and names it (aria-labelledby). Use aria-label when there is no visible label.
descriptiondefault""ReactNodeHelper text under the list, linked with aria-describedby.
errordefault""ReactNodeReplaces the description with the animated error and marks the list invalid (a red ring on the panel, aria-invalid).
requireddefaultfalsebooleanThe red mark after the label and aria-required on the list.
invaliddefaultfalsebooleanThe invalid look and aria-invalid without a message.
disableddefaultfalsebooleanDims the list to 50% and blocks picking, searching and focus. Single options take disabled on their own.
namedefault""stringForm field name: one hidden input per selected value (FormData.getAll(name) with multiple). A form reset returns to defaultValue.
aria-labeldefault""stringThe list's accessible name when there is no visible label.
onSelect(option: ListboxOption) => voidCalled after every pick with the option (pickers close here in single mode).
renderOption(option, state: { selected, highlighted, disabled, ranges }) => ReactNodeReplaces a row's content; the row element, its aria, its classes and the indicator stay.
labelsPartial<{ 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").
fieldPropsPartial<FieldProps>Anything else for the field wrapper: optional, labelAction, orientation, hideLabel.
classNamesPartial<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
hookThe 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
componentShares a useListbox state with ListboxSearch, ListboxList and ListboxStatus. Props: { value: ListboxState; children: ReactNode }.
ListboxSearch
componentThe 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
componentThe 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
componentThe empty / loading / error block alone. Props: { status?: "empty" | "loading" | "error"; emptyText?; loadingText?; errorText?; retryLabel?; onRetry? }.
LISTBOX_ROW
constantThe row classes per density, for custom rows that match. Type: Record<ListboxDensity, { base; description }>.
flattenOptions
functionEvery option of a list, with groups flattened. Signature: (items) => ListboxOption[].
optionText
functionAn 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.
Dependencies
Files
- components/ui/listbox.tsx
FAQ
Is the React Listbox component free?
Yes. The source is free and needs no account: copy it from the Code tab or install it with the CLI.
How do I install Listbox?
Run npx wingo-ui@latest add listbox. It also adds the Wingo UI items it builds on (Field, Checkbox, Spinner, Button, Text match and 6 more) and the npm packages @tanstack/react-virtual, motion and lucide-react. With shadcn, run npx shadcn@latest add https://wingo-ui.com/r/listbox.json.
How does Listbox behave on phones?
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.
See also
Used in
More React input components
Articles using this component
- React Combobox: Async Search Without Race Conditions
Component guides10 min read