Installation
Complete project setup first. Your configuration
must select react/radix and include your Design System ID.
pnpm exec frontfriend download multi-select -f react --primitive radixReview dependencies reported by the downloader and keep your source changes in version control.
Usage
import { MultiSelect } from "@/components/ui/multi-select";
import * as React from "react";
const countryList = [
{ value: "italy", label: "Italy", keywords: ["roma", "it"] },
{ value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
{ value: "france", label: "France", keywords: ["paris", "fr"] },
{ value: "germany", label: "Germany", keywords: ["berlin", "de"] },
{ value: "usa", label: "United States", keywords: ["america", "us"] },
];
const TRIGGER_WIDTH = "min-w-[260px] w-auto";
export default function MultiSelectExample() {
const [value, setValue] = React.useState(["italy", "uk"]);
return (
<MultiSelect
placeholder="Select..." maxCount={3} selectAll={false} clearable createNew={false} batchCreate={false} searchPlaceholder="Search..." closeOnSelect={false} options={countryList} className={TRIGGER_WIDTH}
value={value}
onValueChange={setValue}
/>
);
} Behavior
- createNew and batchCreate are opt-in; batchCreate is inactive when createNew is false.
- Batch creation uses comma, semicolon, tab, and newline separators. Paste with a separator is processed immediately; typed batches are processed on Enter or Create new.
- normalizeNewValue applies only to new candidates before empty checks, validation, deduplication, and insertion.
- validateNewValue is synchronous and runs per normalized candidate. onCreateNew runs only after a new option is accepted.
- Valid unique candidates are retained when other batch candidates are invalid or duplicated; value bindings update once per batch.
- Existing unselected options are selected rather than recreated; existing selected values and repeated normalized candidates count as duplicates.
- options remains authoritative. Created selected values render with label ?? value and do not warn or become persistent searchable options until the consumer adds them to options.
- Search is trimmed, case-insensitive, diacritic-insensitive substring matching over label, value, and keywords, with one target-native filtering pass and no public custom filter callback.
- Filtered Select all adds missing visible options while preserving hidden selections; when every visible option is selected it removes only visible options.
- Batch validation errors remain visible after partial success. Duplicate-only batches show duplicateErrorMessage; mixed successful batches report duplicates through announcements without a visual duplicate error.
- The list exposes listbox semantics and rows expose option plus aria-selected. Internal checkboxes and icons are decorative; ariaLabel falls back to label.
- Announcements have English defaults and accept partial semantic formatter overrides without hardcoded selected/not-selected text in option labels.
- closeOnSelect closes only after selecting a previously unselected option; deselection keeps the popover open.
Examples
Standard selection
import { MultiSelect } from "@/components/ui/multi-select";
import * as React from "react";
const countryList = [
{ value: "italy", label: "Italy", keywords: ["roma", "it"] },
{ value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
{ value: "france", label: "France", keywords: ["paris", "fr"] },
{ value: "germany", label: "Germany", keywords: ["berlin", "de"] },
{ value: "usa", label: "United States", keywords: ["america", "us"] },
];
const TRIGGER_WIDTH = "min-w-[260px] w-auto";
export default function MultiSelectExample() {
const [selected, setSelected] = React.useState<string[]>(["italy", "uk"]);
return (
<MultiSelect
placeholder="Select..." maxCount={3} selectAll={false} clearable createNew={false} batchCreate={false} searchPlaceholder="Search..." closeOnSelect={false} options={countryList} className={TRIGGER_WIDTH}
value={selected}
onValueChange={setSelected}
/>
);
} Controlled and uncontrolled values
import { MultiSelect } from "@/components/ui/multi-select";
import * as React from "react";
const countryList = [
{ value: "italy", label: "Italy", keywords: ["roma", "it"] },
{ value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
{ value: "france", label: "France", keywords: ["paris", "fr"] },
{ value: "germany", label: "Germany", keywords: ["berlin", "de"] },
{ value: "usa", label: "United States", keywords: ["america", "us"] },
];
const TRIGGER_WIDTH = "min-w-[260px] w-auto";
export default function MultiSelectExample() {
const [controlled, setControlled] = React.useState<string[]>(["italy"]);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<MultiSelect
placeholder="Select..." maxCount={3} selectAll={false} clearable createNew={false} batchCreate={false} searchPlaceholder="Search..." closeOnSelect={false} options={countryList} className={TRIGGER_WIDTH}
value={controlled}
onValueChange={setControlled}
/>
<button className="text-sm underline" onClick={() => setControlled([])}>
Reset
</button>
</div>
<MultiSelect
placeholder="Select..." maxCount={3} selectAll={false} clearable createNew={false} batchCreate={false} searchPlaceholder="Search..." closeOnSelect={false} options={countryList} className={TRIGGER_WIDTH}
defaultValue={["france"]}
onValueChange={() => {}}
/>
</div>
);
} Single create
import { MultiSelect } from "@/components/ui/multi-select";
import * as React from "react";
const countryList = [
{ value: "italy", label: "Italy", keywords: ["roma", "it"] },
{ value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
{ value: "france", label: "France", keywords: ["paris", "fr"] },
{ value: "germany", label: "Germany", keywords: ["berlin", "de"] },
{ value: "usa", label: "United States", keywords: ["america", "us"] },
];
const TRIGGER_WIDTH = "min-w-[260px] w-auto";
export default function MultiSelectExample() {
const [selected, setSelected] = React.useState<string[]>([]);
const [created, setCreated] = React.useState<string[]>([]);
return (
<div className="flex flex-col gap-2">
<MultiSelect
placeholder="Select..." maxCount={3} selectAll={false} clearable createNew batchCreate={false} searchPlaceholder="Search..." closeOnSelect={false} options={countryList} className={TRIGGER_WIDTH}
value={selected}
onValueChange={setSelected}
onCreateNew={(option) => setCreated((all) => [...all, option.value])}
/>
<p className="text-sm">Created: {created.join(", ") || "—"}</p>
</div>
);
} Normalized partial batch create
import { MultiSelect } from "@/components/ui/multi-select";
import * as React from "react";
const TRIGGER_WIDTH = "min-w-[260px] w-auto";
export default function MultiSelectExample() {
const [selected, setSelected] = React.useState<string[]>([]);
return (
<MultiSelect
placeholder="Add recipients..." maxCount={3} selectAll={false} clearable createNew batchCreate searchPlaceholder="Search..." closeOnSelect={false} options={[]} className={TRIGGER_WIDTH}
value={selected}
onValueChange={setSelected}
normalizeNewValue={(value) => value.trim().toLowerCase()}
validateNewValue={(value) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)}
validationErrorMessage="Enter a valid email address"
duplicateErrorMessage="Already added"
/>
);
} Label, value and keyword search
import { MultiSelect } from "@/components/ui/multi-select";
import * as React from "react";
const countryList = [
{ value: "italy", label: "Italy", keywords: ["roma", "it"] },
{ value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
{ value: "france", label: "France", keywords: ["paris", "fr"] },
{ value: "germany", label: "Germany", keywords: ["berlin", "de"] },
{ value: "usa", label: "United States", keywords: ["america", "us"] },
];
const TRIGGER_WIDTH = "min-w-[260px] w-auto";
export default function MultiSelectExample() {
const [selected, setSelected] = React.useState<string[]>([]);
return (
<MultiSelect
placeholder="Select..." maxCount={3} selectAll={false} clearable createNew={false} batchCreate={false} searchPlaceholder="Search..." closeOnSelect={false} options={countryList} className={TRIGGER_WIDTH}
value={selected}
onValueChange={setSelected}
/>
);
} Filtered Select all merge and removal
import { MultiSelect } from "@/components/ui/multi-select";
import * as React from "react";
const countryList = [
{ value: "italy", label: "Italy", keywords: ["roma", "it"] },
{ value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
{ value: "france", label: "France", keywords: ["paris", "fr"] },
{ value: "germany", label: "Germany", keywords: ["berlin", "de"] },
{ value: "usa", label: "United States", keywords: ["america", "us"] },
];
const TRIGGER_WIDTH = "min-w-[260px] w-auto";
export default function MultiSelectExample() {
const [selected, setSelected] = React.useState<string[]>(["italy"]);
return (
<MultiSelect
placeholder="Select..." maxCount={3} selectAll clearable createNew={false} batchCreate={false} searchPlaceholder="Search..." closeOnSelect={false} options={countryList} className={TRIGGER_WIDTH}
value={selected}
onValueChange={setSelected}
/>
);
} Accessible announcements and selected state
import { MultiSelect } from "@/components/ui/multi-select";
import * as React from "react";
const countryList = [
{ value: "italy", label: "Italy", keywords: ["roma", "it"] },
{ value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
{ value: "france", label: "France", keywords: ["paris", "fr"] },
{ value: "germany", label: "Germany", keywords: ["berlin", "de"] },
{ value: "usa", label: "United States", keywords: ["america", "us"] },
];
const TRIGGER_WIDTH = "min-w-[260px] w-auto";
export default function MultiSelectExample() {
const [selected, setSelected] = React.useState<string[]>([]);
return (
<MultiSelect
placeholder="Select..." maxCount={3} selectAll clearable createNew={false} batchCreate={false} searchPlaceholder="Search..." closeOnSelect={false} options={countryList} className={TRIGGER_WIDTH}
value={selected}
onValueChange={setSelected}
announcements={{ selected: (option) => `${option.label} selezionato` }}
clearAriaLabel="Rimuovi tutte le selezioni"
/>
);
} contentClassName customisation
import { MultiSelect } from "@/components/ui/multi-select";
import * as React from "react";
const countryList = [
{ value: "italy", label: "Italy", keywords: ["roma", "it"] },
{ value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
{ value: "france", label: "France", keywords: ["paris", "fr"] },
{ value: "germany", label: "Germany", keywords: ["berlin", "de"] },
{ value: "usa", label: "United States", keywords: ["america", "us"] },
];
const TRIGGER_WIDTH = "min-w-[260px] w-auto";
export default function MultiSelectExample() {
const [selected, setSelected] = React.useState<string[]>([]);
return (
<MultiSelect
placeholder="Select..." maxCount={3} selectAll={false} clearable createNew={false} batchCreate={false} searchPlaceholder="Search..." closeOnSelect={false} options={countryList} className={TRIGGER_WIDTH}
value={selected}
onValueChange={setSelected}
contentClassName="max-h-64 shadow-lg"
/>
);
} Custom search and close on select
import { MultiSelect } from "@/components/ui/multi-select";
import * as React from "react";
const countryList = [
{ value: "italy", label: "Italy", keywords: ["roma", "it"] },
{ value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
{ value: "france", label: "France", keywords: ["paris", "fr"] },
{ value: "germany", label: "Germany", keywords: ["berlin", "de"] },
{ value: "usa", label: "United States", keywords: ["america", "us"] },
];
const TRIGGER_WIDTH = "min-w-[260px] w-auto";
export default function MultiSelectExample() {
const [selected, setSelected] = React.useState<string[]>([]);
return (
<MultiSelect
placeholder="Select..." maxCount={3} selectAll={false} clearable createNew={false} batchCreate={false} searchPlaceholder="Find a country" closeOnSelect options={countryList} className={TRIGGER_WIDTH}
value={selected}
onValueChange={setSelected}
/>
);
} API reference
Frontfriend-owned props. Native HTML and inherited primitive props remain documented by Radix UI.
| Prop | Type | Default | Description |
|---|---|---|---|
animation | number | — | |
announcements | Partial<MultiSelectAnnouncements> | — | |
batchCreate | boolean | — | Split one create into several values on comma, semicolon, tab or newline |
clearable | boolean | — | |
clearAriaLabel | string | — | |
closeOnSelect | boolean | — | |
contentClassName | string | — | |
createNew | boolean | — | Allow values absent from `options` to be created from the search box |
createNewText | string | — | |
defaultValue | string[] | — | |
duplicateErrorMessage | string | — | |
emptySearchText | string | — | |
fullWidth | boolean | — | |
listAriaLabel | string | — | |
maxCount | number | — | |
modalPopover | boolean | — | |
moreBadgeSubtle | boolean | — | |
moreBadgeText | string | — | |
moreBadgeVariant | BadgeVariant | — | |
normalizeNewValue | (value: string) => string | — | |
onCreateNew | (option: MultiSelectOption) => void | — | |
onValueChange | (value: string[]) => void | — | |
options | MultiSelectOption[] | — | |
placeholder | string | — | |
searchAriaLabel | string | — | |
searchPlaceholder | string | — | |
selectAll | boolean | — | |
selectAllText | string | — | |
validateNewValue | (value: string) => boolean | — | |
validationErrorMessage | string | — | |
value | string[] | — |
Configuration determines the actual classes and available styling hooks. See Theming & component configuration before introducing a repeated local override.