react-multiselect-ui
v2.1.0
Published
ShadCN-style accessible Multi Select for React 17/18/19 with Tailwind CSS v3/v4
Maintainers
Readme
react-multiselect-ui
A ShadCN-style, fully accessible Multi Select component for React — built with Tailwind CSS, Radix UI, and cmdk.
Features
- ✅ Select multiple options with badge display
- ✅ Searchable dropdown powered by
cmdk - ✅ Keyboard navigation (Arrow keys, Enter, Escape)
- ✅ Full WAI-ARIA accessibility
- ✅ Dark mode support out of the box
- ✅ Max selection limit (
maxCount) - ✅ Clear all / remove individual selections
- ✅ TypeScript — fully typed props and exports
- ✅ ShadCN-compatible Tailwind styling
- ✅ No lucide-react dependency — uses inline SVGs
- ✅ Supports React 17, 18, and 19
- ✅ Supports Tailwind CSS v3 and v4
Compatibility
| Tool | Supported Versions | |---|---| | React | 17, 18, 19 | | Tailwind CSS | v3, v4 | | TypeScript | 4.x, 5.x | | Node.js | 18+ |
Installation
# npm
npm install react-multiselect-ui
# yarn
yarn add react-multiselect-ui
# pnpm
pnpm add react-multiselect-uiPeer Dependencies
Make sure these are already in your project:
npm install react react-domTailwind Setup
This step is required for the component to be styled correctly. Skip it and the component will render unstyled.
Tailwind v4
Add the @source directive so Tailwind scans the component's class names:
/* src/index.css or src/globals.css */
@import "tailwindcss";
/* Required — tells Tailwind v4 to scan the package */
@source "../../node_modules/react-multiselect-ui/dist";Adjust the relative path based on where your CSS file lives:
- CSS at
src/index.css→ use../../node_modules/...- CSS at root
styles/globals.css→ use../node_modules/...- CSS at root
index.css→ use./node_modules/...
Tailwind v3
Add the dist path to the content array in tailwind.config.js:
// tailwind.config.js
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
// Required — tells Tailwind v3 to scan the package
"./node_modules/react-multiselect-ui/dist/**/*.{js,mjs}",
],
theme: {
extend: {},
},
plugins: [],
};Quick Start
import { useState } from "react";
import { MultiSelect } from "react-multiselect-ui";
const options = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
{ value: "angular", label: "Angular" },
];
export default function App() {
const [selected, setSelected] = useState<string[]>([]);
return (
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
placeholder="Select frameworks..."
/>
);
}Examples
1. Basic Usage
import { useState } from "react";
import { MultiSelect } from "react-multiselect-ui";
const FRUITS = [
{ value: "apple", label: "Apple" },
{ value: "banana", label: "Banana" },
{ value: "cherry", label: "Cherry" },
{ value: "mango", label: "Mango" },
{ value: "orange", label: "Orange" },
];
export function BasicExample() {
const [selected, setSelected] = useState<string[]>([]);
return (
<div className="w-80">
<MultiSelect
options={FRUITS}
value={selected}
onValueChange={setSelected}
placeholder="Pick some fruits..."
/>
<p className="mt-2 text-sm text-gray-500">
Selected: {selected.length === 0 ? "none" : selected.join(", ")}
</p>
</div>
);
}2. Pre-selected Values
Initialize useState with values to show defaults on first render:
export function PreselectedExample() {
const [languages, setLanguages] = useState<string[]>(["en", "es"]);
return (
<MultiSelect
options={[
{ value: "en", label: "English" },
{ value: "es", label: "Spanish" },
{ value: "fr", label: "French" },
{ value: "de", label: "German" },
{ value: "zh", label: "Chinese" },
]}
value={languages}
onValueChange={setLanguages}
placeholder="Select languages..."
/>
);
}3. Max Selection Limit
Use maxCount to cap how many items can be selected. Options automatically disable once the limit is reached:
export function MaxSelectExample() {
const [toppings, setToppings] = useState<string[]>([]);
return (
<div className="w-80">
<MultiSelect
options={[
{ value: "cheese", label: "Extra Cheese" },
{ value: "mushrooms", label: "Mushrooms" },
{ value: "peppers", label: "Bell Peppers" },
{ value: "onions", label: "Onions" },
{ value: "olives", label: "Olives" },
]}
value={toppings}
onValueChange={setToppings}
placeholder="Select toppings..."
maxCount={3}
/>
{toppings.length === 3 && (
<p className="mt-1 text-xs text-amber-600">
Maximum 3 toppings reached!
</p>
)}
</div>
);
}4. Disabled Component
Disable the entire component conditionally:
export function DisabledExample() {
const [isLocked, setIsLocked] = useState(true);
const [roles, setRoles] = useState<string[]>(["viewer"]);
return (
<div className="space-y-2 w-80">
<MultiSelect
options={[
{ value: "admin", label: "Admin" },
{ value: "editor", label: "Editor" },
{ value: "viewer", label: "Viewer" },
{ value: "guest", label: "Guest" },
]}
value={roles}
onValueChange={setRoles}
placeholder="Select roles..."
disabled={isLocked}
/>
<button
onClick={() => setIsLocked((v) => !v)}
className="text-sm text-blue-600 underline"
>
{isLocked ? "Unlock" : "Lock"} selector
</button>
</div>
);
}5. Disabled Individual Options
Mark specific options as unselectable while keeping others available:
const PLANS = [
{ value: "free", label: "Free Tier" },
{ value: "pro", label: "Pro" },
{ value: "team", label: "Team" },
// This option cannot be selected
{ value: "enterprise", label: "Enterprise — contact sales", disabled: true },
];
export function DisabledOptionsExample() {
const [plan, setPlan] = useState<string[]>([]);
return (
<MultiSelect
options={PLANS}
value={plan}
onValueChange={setPlan}
placeholder="Select a plan..."
/>
);
}6. Inside a Form with Reset
export function FormExample() {
const [skills, setSkills] = useState<string[]>([]);
const [submitted, setSubmitted] = useState<string[]>([]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setSubmitted([...skills]);
setSkills([]); // Reset after submit
};
return (
<form onSubmit={handleSubmit} className="space-y-3 w-80">
<MultiSelect
options={[
{ value: "typescript", label: "TypeScript" },
{ value: "react", label: "React" },
{ value: "node", label: "Node.js" },
{ value: "python", label: "Python" },
{ value: "go", label: "Go" },
{ value: "rust", label: "Rust" },
]}
value={skills}
onValueChange={setSkills}
placeholder="Select your skills..."
/>
<button
type="submit"
disabled={skills.length === 0}
className="w-full rounded-md bg-zinc-900 px-4 py-2 text-sm
font-medium text-white disabled:opacity-50"
>
Submit
</button>
{submitted.length > 0 && (
<p className="text-sm text-green-700">
Submitted: {submitted.join(", ")}
</p>
)}
</form>
);
}7. Without Clear Button
Hide the "Clear all" control so users can only deselect via individual badge X buttons:
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
placeholder="Add labels..."
clearable={false}
/>8. Custom Search and Empty Text
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
placeholder="Choose team members..."
searchPlaceholder="Type a name to search..."
emptyText="No team members match your search."
/>9. Custom Styling
Use className to style the trigger button and contentClassName for the dropdown panel:
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
placeholder="Pick colors..."
className="border-violet-300 focus-visible:ring-violet-500"
contentClassName="border-violet-200"
/>10. Async / API-loaded Options
import { useState, useEffect } from "react";
import { MultiSelect } from "react-multiselect-ui";
import type { MultiSelectOption } from "react-multiselect-ui";
export function AsyncExample() {
const [options, setOptions] = useState<MultiSelectOption[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/users")
.then((res) => res.json())
.then((data) => {
setOptions(
data.map((u: { id: string; name: string }) => ({
value: u.id,
label: u.name,
}))
);
setLoading(false);
});
}, []);
if (loading) {
return (
<div className="h-9 w-full animate-pulse rounded-md bg-zinc-100" />
);
}
return (
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
placeholder="Assign team members..."
searchPlaceholder="Search by name..."
/>
);
}11. Accessible with ARIA Label
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
placeholder="Filter by department..."
aria-label="Filter results by department"
/>12. Controlled Reset from Parent
export function ParentControlExample() {
const [selected, setSelected] = useState<string[]>(["react", "typescript"]);
return (
<div className="space-y-3 w-80">
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
placeholder="Select technologies..."
/>
<div className="flex gap-2">
<button onClick={() => setSelected([])}>
Clear All
</button>
<button onClick={() => setSelected(["react", "typescript", "node"])}>
Reset to Defaults
</button>
</div>
</div>
);
}API Reference
MultiSelectOption
interface MultiSelectOption {
value: string; // Unique identifier stored in the value array
label: string; // Display text shown in the dropdown and badges
disabled?: boolean; // Prevents this option from being selected
}MultiSelectProps
| Prop | Type | Default | Description |
|---|---|---|---|
| options | MultiSelectOption[] | required | Array of selectable options |
| value | string[] | [] | Currently selected values (controlled) |
| onValueChange | (value: string[]) => void | — | Called when selection changes |
| placeholder | string | "Select options..." | Shown when nothing is selected |
| searchPlaceholder | string | "Search..." | Placeholder inside the search input |
| emptyText | string | "No options found." | Shown when search has no results |
| disabled | boolean | false | Disables the entire component |
| maxCount | number | — | Maximum number of selectable items |
| clearable | boolean | true | Show / hide the clear all control |
| className | string | — | Extra classes for the trigger button |
| contentClassName | string | — | Extra classes for the dropdown panel |
| aria-label | string | — | Accessible label for screen readers |
Keyboard Navigation
| Key | Action |
|---|---|
| Space / Enter | Open dropdown / select focused option |
| ↑ / ↓ | Navigate through options |
| Escape | Close the dropdown |
| Tab | Move focus out of the dropdown |
TypeScript
All types are exported from the package root:
import { MultiSelect } from "react-multiselect-ui";
import type { MultiSelectProps, MultiSelectOption } from "react-multiselect-ui";
// Build options dynamically with full type safety
const buildOptions = (
items: { id: string; name: string }[]
): MultiSelectOption[] =>
items.map((item) => ({ value: item.id, label: item.name }));Framework Guides
Next.js App Router
Add "use client" at the top of any file that uses the component since it uses React state:
"use client";
import { useState } from "react";
import { MultiSelect } from "react-multiselect-ui";
export default function Page() {
const [selected, setSelected] = useState<string[]>([]);
return (
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
/>
);
}Next.js Pages Router
Works without any extra configuration:
import { useState } from "react";
import { MultiSelect } from "react-multiselect-ui";
export default function Page() {
const [selected, setSelected] = useState<string[]>([]);
return (
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
/>
);
}Vite + React
Works out of the box. Just follow the Tailwind setup above.
Remix
// app/routes/_index.tsx
import { useState } from "react";
import { MultiSelect } from "react-multiselect-ui";
export default function Index() {
const [selected, setSelected] = useState<string[]>([]);
return (
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
/>
);
}Troubleshooting
Component renders with no styles
You are missing the Tailwind scan configuration. See the Tailwind Setup section above for your version (v3 or v4).
Dropdown appears behind modals or sticky headers
The dropdown uses a Radix UI Portal (mounts directly on <body>). Override the z-index via contentClassName:
<MultiSelect contentClassName="z-[9999]" ... />TypeScript error: Cannot find module
Make sure you import from the package root:
// ✅ Correct
import { MultiSelect } from "react-multiselect-ui";
import type { MultiSelectOption } from "react-multiselect-ui";
// ❌ Wrong — never import from internal paths
import { MultiSelect } from "react-multiselect-ui/dist/index.mjs";React 17 — JSX transform error
Make sure your tsconfig has the new JSX transform:
{
"compilerOptions": {
"jsx": "react-jsx"
}
}Migrating from @parag.vora/react-multiselect-ui
# Remove old package
npm uninstall @parag.vora/react-multiselect-ui
# Install new package
npm install react-multiselect-uiUpdate your imports:
// Before
import { MultiSelect } from "@parag.vora/react-multiselect-ui";
// After
import { MultiSelect } from "react-multiselect-ui";The API is identical — no other changes needed.
Contributing
Contributions, issues, and feature requests are welcome!
- Fork the repository
- Create your branch:
git checkout -b feat/my-feature - Commit:
git commit -m "feat: add my feature" - Push:
git push origin feat/my-feature - Open a Pull Request on GitHub
License
MIT © Parag Vora
