@mshafiqyajid/react-multi-select
Headless hook and styled multi-select dropdown for React. Chips display, count badge, searchable, select-all with indeterminate state, grouped options, max selection limit, label/hint/error form control support, portal positioning, and full keyboard navigation.
Playground #
import { MultiSelectStyled } from "@mshafiqyajid/react-multi-select/styled";
import "@mshafiqyajid/react-multi-select/styles.css";
<MultiSelectStyled
options={options}
value={value}
onChange={setValue}
/>Grouped options
Options with groups and a pre-selected state
Form control
Select all that apply to your project.
Please select at least one framework.
Max selection
maxSelected=2 β try selecting a third option
Trigger modes
triggerMode="chips" β always shows chips
triggerMode="count" β always shows count badge
triggerMode="auto" maxChips=2 β chips up to 2, then count
Install #
npm install @mshafiqyajid/react-multi-select Quick start #
import { MultiSelectStyled } from "@mshafiqyajid/react-multi-select/styled";
import "@mshafiqyajid/react-multi-select/styles.css";
import { useState } from "react";
const options = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
];
const [value, setValue] = useState<string[]>([]);
<MultiSelectStyled
options={options}
value={value}
onChange={setValue}
placeholder="Select frameworksβ¦"
/> Grouped options #
Add a group key to any option β the dropdown renders section headers automatically.
const options = [
{ value: "react", label: "React", group: "Frontend" },
{ value: "vue", label: "Vue", group: "Frontend" },
{ value: "nextjs", label: "Next.js", group: "Full-stack" },
{ value: "nuxt", label: "Nuxt", group: "Full-stack" },
];
<MultiSelectStyled options={options} value={value} onChange={setValue} /> Max selection #
<MultiSelectStyled
options={options}
value={value}
onChange={setValue}
maxSelected={3}
placeholder="Pick up to 3β¦"
/> Trigger modes #
triggerMode="auto" shows chips up to maxChips (default 3), then switches to a count badge. Use "chips" to always show pills, or "count" to always show the badge.
// Auto (default): chips up to 3, then "N selected"
<MultiSelectStyled triggerMode="auto" maxChips={3} ... />
// Always chips
<MultiSelectStyled triggerMode="chips" ... />
// Always count
<MultiSelectStyled triggerMode="count" ... /> Form control #
<MultiSelectStyled
options={options}
value={value}
onChange={setValue}
label="Frameworks"
hint="Select all that apply."
required
/>
// With validation error
<MultiSelectStyled
options={options}
value={value}
onChange={setValue}
label="Frameworks"
error="Please select at least one framework."
invalid
/> Custom option render #
<MultiSelectStyled
options={options}
value={value}
onChange={setValue}
renderOption={(option, { selected, focused }) => (
<span style={{ display: "flex", gap: "0.5rem", width: "100%" }}>
<span style={{ color: selected ? "#6366f1" : "#a1a1aa" }}>
{selected ? "β" : "β"}
</span>
<span style={{ fontWeight: selected ? 600 : undefined }}>
{option.label}
</span>
</span>
)}
/> Headless hook #
import { useMultiSelect } from "@mshafiqyajid/react-multi-select";
const options = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
];
const {
triggerProps,
listboxProps,
getOptionProps,
isOpen,
selectedValues,
isAllSelected,
isIndeterminate,
selectAll,
clearAll,
filteredOptions,
} = useMultiSelect({ options });
return (
<>
<button {...triggerProps}>
{selectedValues.length > 0 ? `${selectedValues.length} selected` : "Selectβ¦"}
</button>
{isOpen && (
<ul {...listboxProps}>
{filteredOptions.map((opt) => (
<li key={opt.value} {...getOptionProps(opt)}>
{opt.label}
</li>
))}
</ul>
)}
</>
); API #
| Prop | Type | Default | Description |
|---|---|---|---|
| options | MultiSelectOption[] | β | Array of { value, label, group?, disabled? } |
| value | string[] | β | Controlled selected values |
| defaultValue | string[] | [] | Uncontrolled initial selection |
| onChange | (v: string[]) => void | β | Called when selection changes |
| placeholder | string | "Select optionsβ¦" | Trigger placeholder |
| searchable | boolean | true | Show search input |
| searchPlaceholder | string | "Searchβ¦" | Search input placeholder |
| emptyText | string | "No results." | Text when no options match search |
| showSelectAll | boolean | true | Show select-all option |
| maxSelected | number | β | Maximum selections allowed |
| clearable | boolean | true | Show clear-all button |
| triggerMode | "chips" | "count" | "auto" | "auto" | How selections appear in trigger |
| maxChips | number | 3 | Chip count before switching to badge (auto mode) |
| size | "sm" | "md" | "lg" | "md" | Height: 32 / 40 / 48px |
| tone | "neutral" | "primary" | "success" | "danger" | "neutral" | Color tone |
| disabled | boolean | false | Disable interaction |
| required | boolean | false | Mark as required |
| invalid | boolean | false | Show invalid state |
| label | string | β | Label above trigger |
| hint | string | β | Helper text below trigger |
| error | string | β | Error message (enables invalid state) |
| placement | "auto" | "top" | "bottom" | "auto" | Dropdown placement |
| renderOption | (opt, state) => ReactNode | β | Custom option renderer |
| renderTrigger | (selected) => ReactNode | β | Custom trigger content renderer |