Multi Select React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React multi select: pick several options shown as chips with a +N overflow, with search, select all, a maximum and async options.
Click or tap the field to open the list under it; every row has a checkbox, and clicking a row toggles it while the list stays open (closeOnSelect closes it after each pick). The chosen options show as chips in the field: as many as fit on one line, measured as the field resizes, then a "+N" chip whose number crossfades; chips slide in and out as you pick. Each chip's x removes it without opening the list, and an x at the end of the field clears everything. The footer under the list shows the count ("3 selected", or "2 of 3 selected" with a maximum) and a Clear all button; at the maximum the unselected rows turn disabled. With selectAll a row on top toggles every visible option. Above 8 options (or with loadOptions, creatable or search="always") a search row sits on top and takes focus: typing filters, case and diacritics never matter and matched letters turn bold; Backspace in the empty search removes the last chip. display="count" shows "3 selected" instead of chips, display="text" the names joined and truncated, and wrap lets chips flow onto more lines while the field grows. Keyboard: Tab reaches the field (chip x buttons stay out of the tab order) and a click on its label focuses it. While closed, Enter, Space and ArrowDown open it, ArrowUp opens on the last option, Home and End open at the ends, and Backspace removes the last chip. While open, ArrowDown and ArrowUp move and wrap, PageUp and PageDown jump, Enter or Space toggle the highlighted row and keep the list open, Cmd/Ctrl+A selects all (when the list, not the search, has focus), Escape or Alt+ArrowUp close and keep focus on the field, Tab closes and moves on. On a phone the field opens a bottom sheet titled with the label, with 48px checkbox rows and a sticky footer: "Done (3)" commits the picks and "Clear all" empties the draft; dragging the sheet down, tapping outside or Escape discards the changes. With name, one hidden input per value submits (FormData.getAll), an empty required field blocks the submit and a form reset restores the default. With reduced motion chips and the list appear without sliding or scaling.
Multi Select 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 multi-select, together with UIProvider, Listbox, Field, Drawer, Badge and 6 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 multi-selectMulti Select 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 MultiSelect from @/components/ui/multi-select.
import { useState } from "react"
import { Car, Building2, Smartphone, Dumbbell } from "lucide-react"
import { MultiSelect, type MultiSelectOption } from "@/components/ui/multi-select"
const CATEGORIES: MultiSelectOption[] = [
{ value: "real-estate", label: "Real estate", icon: <Building2 /> },
{ value: "vehicles", label: "Vehicles", icon: <Car /> },
{ value: "electronics", label: "Electronics", icon: <Smartphone /> },
{ value: "sports", label: "Sports", icon: <Dumbbell /> },
]
export function FollowedCategories() {
const [categories, setCategories] = useState<string[]>(["vehicles"])
return (
<MultiSelect
label="Followed categories"
placeholder="Pick categories"
options={CATEGORIES}
value={categories}
onValueChange={setCategories}
maxSelected={3}
selectAll
name="categories"
labels={{
selected: (n) => `${n} picked`,
limit: (n, max) => `${n} of ${max} picked`,
}}
/>
)
}Props
options*MultiSelectItemsOptions and groups: { value, label, description?, icon?, badge?, disabled?, keywords?, textValue? } or { label, items }. The icon also shows inside the chip.
valuestring[]The chosen values, controlled. Values not among the options keep their chips (labels from the cache, else the value).
defaultValuestring[]The starting values while uncontrolled (empty when omitted). A form reset returns to them.
onValueChange(value: string[]) => voidCalled with the new values after a toggle, a chip removal, select all, clear, or Done in the phone sheet.
labeldefault""ReactNodeThe field label. A click focuses the trigger; plain text also titles the phone sheet and names the trigger with the count.
descriptiondefault""ReactNodeHelper text under the field, linked with aria-describedby.
errordefault""ReactNodeReplaces the description with an animated error and marks the field invalid.
requireddefaultfalsebooleanThe red mark after the label, aria-required on the trigger; with name an empty field blocks the form submit.
invaliddefaultfalsebooleanThe invalid look and aria-invalid without a message.
disableddefaultfalsebooleanDims the field, never opens, chips are not removable and nothing is submitted.
readOnlydefaultfalsebooleanThe chips stay visible and the field focusable, but it never opens and nothing can be removed or cleared.
placeholderdefault"Select options"stringShown in the field while nothing is chosen.
displaydefault"chips""chips" | "count" | "text"How the value shows in the field: chips; "3 selected"; or the names joined and truncated.
maxVisibleChipsdefault0numberAt most this many chips, then +N. 0 shows as many as fit on one line, measured on resize.
wrapdefaultfalsebooleanChips wrap onto more lines and the field grows instead of collapsing into +N.
chipRemovabledefaulttruebooleanEach chip has an x that removes it without opening the list; Backspace on the field removes the last one.
showChipIconsdefaulttruebooleanThe option's icon (an avatar, a flag) inside its chip.
maxSelecteddefault0numberThe most options that can be chosen; at the limit the other rows turn disabled. 0 means no limit.
selectAlldefaultfalsebooleanA "Select all" row on top that toggles every visible option (respecting maxSelected).
clearabledefaulttruebooleanAn x at the end of the field that clears everything while something is chosen.
closeOnSelectdefaultfalsebooleanCloses the popover after every pick. The phone sheet always waits for Done.
searchdefault"auto""auto" | "always" | "never"The search row: auto shows it above 8 options, with loadOptions or with creatable.
searchPlaceholderdefault"Search…"stringThe search row's placeholder.
emptyTextdefault"No results"stringShown when the search matches nothing (or there are no options).
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The chips, the checkboxes and the sheet's Done button.
colordefault""stringAny CSS color for the chips and checkboxes; replaces the tone. Leave empty to use the tone.
loadingdefaultfalsebooleanOptions are on their way: a spinner replaces the chevron and the list shows a loading row.
loadingTextdefault"Loading…"stringThe loading row's text and the spinner's accessible name.
highlightMatchesdefaulttruebooleanBold matched letters while searching.
leftIconReactNodeA leading icon in the field.
sizedefault"md""sm" | "md" | "lg"32 / 40 / 48px (md grows to 44px on touch) with 20 / 24 / 28px chips, 16px text on phones.
variantdefault"soft""soft" | "outline" | "underline" | "ghost"The field look, the same as every other field.
radiusdefault"md""none" | "sm" | "md" | "lg" | "full"Corner radius, scaled per size like the button; full also makes the chips pills. Ignored by underline.
namedefault""stringRenders one hidden input per value with this name, so FormData.getAll(name), required and form reset work.
idstringThe trigger's id (useId() by default); the label points at it.
fieldPropsPartial<FieldProps>Anything else for the Field wrapper: optional, labelAction, orientation, labelWidth, hideLabel.
defaultOpendefaultfalsebooleanStarts with the list (or the sheet) open. Pass open and onOpenChange to control it.
openbooleanWhether the list is open, controlled. It never opens while disabled or readOnly.
onOpenChange(open: boolean) => voidCalled when the list opens or closes.
responsivedefaulttruebooleanA bottom sheet with 48px checkbox rows and a Done button below 768px instead of the popover.
sheetTitledefault""stringThe phone sheet's title. Empty uses the label's text, else aria-label, else the placeholder.
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 maximum height in px (search and footer included), capped by the room on screen.
virtualThresholddefault100numberAbove this many visible options only the rows in view render.
loadOptions(query: string, context: { signal: AbortSignal }) => Promise<MultiSelectItems>Async options for the debounced query; the previous request is aborted and results are cached per query.
debouncedefault250numberms to wait after typing before loadOptions runs.
minQueryLengthdefault0numberloadOptions waits for this many characters.
creatabledefaultfalsebooleanA create row when the query matches nothing exactly; pair it with onCreate.
onCreate(query: string) => (string | void | Promise<string | void>)Creates an option from the query; a returned value is selected.
createLabel(query: string) => ReactNodeThe create row's text (default: Create "query").
filterboolean, or (option: MultiSelectOption, query: string) => score (number, null hides)Default true: true filters with text-match; false means the options are already filtered; a function scores (null hides).
fuzzydefaultfalsebooleanFuzzy matching for the search.
renderOption(option, state: { selected, highlighted, disabled, ranges }) => ReactNodeReplaces a row's content; the row element, its checkbox, aria and classes stay.
containerHTMLElement | nullWhere the list or the sheet is portaled (document.body by default).
labelsPartial<MultiSelectLabels>Every string: { selectAll, clearAll, done, selected(n), limit(n, max), more(n), remove(label), results(n), clearSearch, retry }. Defaults: "Select all", "Clear all", "Done", "3 selected", "2 of 3 selected", "+3", "Remove Auto", the announced result count, the search clear button and the retry button.
classNamesPartial<Record<"field" | "label" | "description" | "error" | "counter" | "box" | "trigger" | "icon" | "chips" | "chip" | "overflow" | "placeholder" | "summary" | "clear" | "chevron" | "content" | "search" | "list" | "item" | "group" | "empty" | "footer", string>>Extra classes for the inner parts. box is the field box, trigger the button covering it, content the popover (or the sheet).
classNamestringMerged onto the Field root when there is a label, description or error, otherwise onto the box.
refRefObject<HTMLButtonElement | null> or (node) => voidThe trigger button; other props (onBlur, aria-*, data-*, form) go there too.
Examples
People with avatars
Avatars as option icons, shown in the rows and inside the chips; search by name or role.
A maximum
maxSelected={3}: at the limit the other rows turn disabled and the footer reads “3 of 3 selected”.
Count, text and chips
The same value shown as a count, as a truncated list of names and as chips with a measured +N.
Wrapping chips
wrap lets chips flow onto more lines and the box grows; each chip's x removes it without opening the list.
Groups and select all
Group labels, a disabled row and a “Select all” row that toggles every visible option, in the brand tone.
Async options
loadOptions against a fake API; results stay on screen while the next query loads and selected labels are kept.
Sizes, variants and colors
32, 40 and 48px, the four field looks, a tone and a custom color for chips and checkboxes.
Dependencies
Files
- components/ui/multi-select.tsx
FAQ
Is the React Multi Select component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Multi Select?
Run npx wingo-ui@latest add multi-select. It also adds the Wingo UI items it builds on (UIProvider, Listbox, Field, Drawer, Badge and 6 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/multi-select.
How does Multi Select behave on phones?
A bottom sheet with 48px checkbox rows and a Done button below 768px 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
- React Tag Input: Chips from Typing, Paste and Suggestions
Component guides8 min read