Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelogContact
Components
Buttons & Actions
ButtonButton GroupCopy ButtonDelete ButtonTheme ToggleToggleToggle Group
Inputs
Avatar UploadCalendarCheckboxColor PickerComboboxCountry SelectCron PickerCurrency InputDate PickerDate Range PickerDate Time PickerDuration PickerEmail InputEmoji PickerFile UploadFilter BuilderInputInput GroupListboxMasked InputMention InputMonth PickerMulti SelectNumber InputOTP InputPassword InputPhone InputRadio GroupRatingSearch InputSegmented ControlSelectSignature PadSliderSwitchTag InputTextareaTime PickerTimezone SelectTree Select
Forms
Address InputBooking WidgetCSV ImportFieldForm WizardInline EditInvoice line itemsRich Text EditorSchema Builder
Navigation
Back To TopBounce SidebarBreadcrumbCommand MenuGlobal SearchGooey NavHook SidebarNav MenuNav TabsPaginationProximity SidebarScroll ProgressSidebarStepsTable Of ContentsTabsTopbarUser MenuWorkspace Switcher
Overlays
Alert DialogContext MenuDialogDrawerDropdown MenuFamily DrawerHover CardPopoverShare DialogSheetShortcuts DialogTooltipTour
Feedback
AlertBannerCookie ConsentEmoji ReactionEmpty StateNotification BellNotification CenterProgressRoute ProgressSetup ChecklistSkeletonSpinnerToast
Data Display
Approval FlowAvatarBadgeCalendar ViewCode BlockComment ThreadContact cardCountdownDescription ListDiff ViewerFile ListFile managerFolderGanttJSON ViewerKbdLog ViewerMarkdownPresenceSchedulerTagTimelineTree View
Tables & Lists
Audit LogData TableDeal PipelineEditable GridKanbanListMembers tablePermissions MatrixTableTask ListVirtual List
Charts & Stats
Animated CounterArea chartBar chartDonut chartFunnel chartGaugeGitHub ActivityHeatmap calendarLeaderboardLine chartRadar chartRadial barSparklineStat CardTrackerTrend badge
Layout
AccordionApp ShellCardCollapsibleDetail LayoutEntity HeaderPage HeaderScroll AreaSeparatorSettings LayoutSplit ViewToolbar
Media
Audio PlayerCarouselCSV ViewerDOCX EditorDOCX ViewerExcel ViewerFile SystemImage compareImage CropperImage GalleryImage LightboxLocation MapPDF ViewerPowerPoint ViewerQR CodeStep PlayerVideo Player
AI Kit
Agent Status CardChat MessageChat ThreadChat WidgetConversation ListFluid OrbGrid RevealMatrix OrbModel PickerPrompt InputReasoning IndicatorRule BuilderSuggestion ChipsTool Call TimelineUsage MeterVoice InputWorkflow Canvas
Text & Effects
Background PatternGravity LettersMarqueeTerminalText AnimateText Rotate
Mobile
Action SheetBottom Tab BarFABHaptic ButtonHero HeaderInstall PromptLarge Title HeaderNotification BannerOnboarding CarouselPicker WheelPull To RefreshSlide To ConfirmSticky CTA BarSwipe DeckSwipeable Tabs
Commerce
Cart drawerCategory pickerCheckout stepsCoupon inputFavorite buttonFilter panelListing cardListing gridOrder trackerPricePrice rangePricing tableProduct galleryQuantity stepperReviewsSeller card
Marketing Sections
App Download SectionBlog Cards SectionCTA SectionFAQ SectionFeature GridFeature ShowcaseHero SectionIntegrations SectionLogo CloudMarketing FooterMarketing HeaderMarketing SectionNewsletter SectionPricing SectionStats SectionSteps SectionTeam SectionTestimonials SectionTimeline SectionVideo Section
Blocks
Admin Dashboard PageAdmin ShellAI Chat PageAPI Keys PageAutomation Builder PageCheckout PageCRM Contact PageCRM Pipeline PageError PagesInbox PageInvoice PageKPI DashboardListing Detail PageLogin PageMarketplace Home PageMarketplace Search PageMarketplace ShellMobile App HomeOnboarding FlowOrder History PagePost Listing WizardPricing PageProfile PageRegister PageSettings PageUsers Admin Page
Templates
About PageAcademy PageAccessibility PageAffiliate PageAgency PageAI Product PageAppointment Booking PageBlog PageBlog Post PageBug Bounty PageCareers PageChangelog PageCommunity PageCompare PageContact PageCustomer Story PageCustomers PageDemo Booking PageDevelopers PageDocs Home PageDownload PageEnterprise PageEvents PageExperts PageFAQ PageFeature PageGlossary PageHelp Article PageHelp Center PageHome Services PageImpact PageIntegration Detail PageIntegrations PageJob Detail PageLanding PageLaunch PageLegal PageManifesto PagePortfolio PagePress PageProduct Tour PagePromo PageReal Estate PageReferral PageResearch Report PageRestaurant PageRoadmap PageROI Calculator PageSecurity PageSolutions PageStartups PageStatus PageStorefront PageSwitch PageTemplate Gallery PageTravel PageWaitlist PageYear in Review Page
Hooks & Utilities
AI ChatChart kitCommerceDate utilsFile exportFile typesFiltersForm validationHapticsInvoicingMotion presetsNavigationNumber formatRelative timeRomanian locationsSpreadsheetText matchTonesUIProvideruseClipboarduseConsentuseControllableStateuseCooldownuseDebounceuseKeyboardInsetuseLocaleuseLongPressuseMediaControlsuseMediaQueryuseOverflowItemsusePersistedStateuseScrollEdgesuseScrollStateuseSelectionuseShortcutuseStickToBottom
GalleryTheme

Inputs

Listbox

Inputs

Listbox React component for Tailwind CSS v4

Input GroupMasked Input
Live props
Behavior
multiple
search
debounce250ms
minQueryLength0
fuzzy
creatable
maxSelectedNo limit
selectAll
virtualThreshold100
autoHighlight
loop
typeahead
name
Content
searchPlaceholder
emptyText
loadingText
errorText
selectAllLabel
State
loading
defaultQuery
Appearance
highlightMatches
indicator
density
variant
tone
color
Layout
maxHeight320px
Field
label
description
error
required
invalid
disabled
aria-label
Code
import { Listbox } from "@/components/ui/listbox"

<Listbox />
  1. Home
  2. Components
  3. Inputs
  4. Listbox
Free

Listbox React component for Tailwind CSS v4

v1.0.0·Added 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 listbox

Usage

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*
    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.

  • multipledefault false
    boolean

    Many values: rows get a leading checkbox and toggle; aria-multiselectable is set. Single mode keeps the picked value when you click it again.

  • searchdefault false
    boolean

    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.

  • searchPlaceholderdefault "Search…"
    string

    Placeholder of the search field.

  • emptyTextdefault "No results"
    string

    Shown when nothing matches the query (or the list is empty).

  • loadingTextdefault "Loading…"
    string

    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.

  • errorTextdefault "Could not load results"
    string

    Shown with a Try again button when loadOptions rejects; the last results stay. In the playground a custom text previews the error state.

  • loadingdefault false
    boolean

    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.

  • debouncedefault 250
    number

    ms to wait after typing before loadOptions runs. The first load runs at once.

  • minQueryLengthdefault 0
    number

    loadOptions waits for this many characters; below it the static options show.

  • filterdefault true
    boolean | ((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.

  • fuzzydefault false
    boolean

    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.

  • defaultQuerydefault ""
    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.

  • creatabledefault false
    boolean

    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.

  • createLabeldefault "q => `Create "${q}"`"
    (query: string) => ReactNode

    The Create row's text.

  • maxSelecteddefault 0
    number

    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.

  • selectAlldefault false
    boolean

    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.

  • selectAllLabeldefault "Select all"
    string

    The Select all row's text.

  • highlightMatchesdefault true
    boolean

    Matched 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.

  • maxHeightdefault 320
    number

    px before the list scrolls; group labels stick to the top while their group scrolls. 0 grows with the content.

  • virtualThresholddefault 100
    number

    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.

  • autoHighlightdefault true
    boolean

    Highlight the selected option (or the first match while searching) whenever the query or the options change, so Enter picks at once.

  • loopdefault true
    boolean

    ArrowDown on the last row wraps to the first, ArrowUp on the first to the last.

  • typeaheaddefault true
    boolean

    Letters 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 ""
    string

    Any CSS color; replaces the tone. Leave empty to use the tone.

  • labeldefault ""
    ReactNode

    Renders the field anatomy around the list and names it (aria-labelledby). Use aria-label when there is no visible label.

  • descriptiondefault ""
    ReactNode

    Helper text under the list, linked with aria-describedby.

  • errordefault ""
    ReactNode

    Replaces the description with the animated error and marks the list invalid (a red ring on the panel, aria-invalid).

  • requireddefault false
    boolean

    The red mark after the label and aria-required on the list.

  • invaliddefault false
    boolean

    The invalid look and aria-invalid without a message.

  • disableddefault false
    boolean

    Dims the list to 50% and blocks picking, searching and focus. Single options take disabled on their own.

  • namedefault ""
    string

    Form field name: one hidden input per selected value (FormData.getAll(name) with multiple). A form reset returns to defaultValue.

  • aria-labeldefault ""
    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.

Dependencies

@tanstack/react-virtualmotionlucide-reactfieldcheckboxspinnerbuttontext-matchuse-debounceuse-media-querymotiontonesuse-controllable-stateui-config

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

CheckboxFieldText matchlibuseDebouncehookSpinnerCommand Menu

Used in

Marketplace Home PageLocation MapWorkflow CanvasComboboxEmail InputFilter BuilderMention InputMulti SelectSearch InputSelectTag InputGlobal Search

and 2 more.

More React input components

Month PickerNumber InputPassword InputPhone InputRadio GroupRating
All 40 React input components

Articles using this component

  • React Combobox: Async Search Without Race Conditions

    Component guides · Oct 16, 2026 · 10 min read

PreviousInput GroupNext Masked Input
Pick a state
AlabamaAL
AlaskaAK
ArizonaAZ
ArkansasAR
CaliforniaCA
ColoradoCO
ConnecticutCT
DelawareDE
District of ColumbiaDC
FloridaFL
GeorgiaGA
HawaiiHI
IdahoID
IllinoisIL
IndianaIN
IowaIA
KansasKS
KentuckyKY
LouisianaLA
MaineME
MarylandMD
MassachusettsMA
MichiganMI
MinnesotaMN
MississippiMS
MissouriMO
MontanaMT
NebraskaNE
NevadaNV
New HampshireNH
New JerseyNJ
New MexicoNM
New YorkNY
North CarolinaNC
North DakotaND
OhioOH
OklahomaOK
OregonOR
PennsylvaniaPA
Rhode IslandRI
South CarolinaSC
South DakotaSD
TennesseeTN
TexasTX
UtahUT
VermontVT
VirginiaVA
WashingtonWA
West VirginiaWV
WisconsinWI
WyomingWY

Picked: Oregon

51 options · virtualizes over 100 · exact search · matches highlighted

auto highlight · arrows wrap · typeahead · loads after 250ms