Multi Select
A searchable multi-selection input with removable pill badges, Select All / Deselect All batch actions, collapsible header categories, keyboard navigation, and max count enforcement.
import
import { MultiSelect } from "@/components/ui/multiSelect/multiSelect";$
npx @bloomui-react/cli add multiSelectmultiSelect.tsx
"use client";
import { Icon } from "@iconify/react";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
const multiSelectVariants = cva(
"flex flex-wrap items-center gap-1.5 min-h-[42px] px-3 py-2 rounded-2xl transition-colors cursor-text",
{
variants: {
variant: {
default:
"bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-xs focus-within:border-sky-500 focus-within:ring-2 focus-within:ring-sky-500/40 text-zinc-900 dark:text-zinc-100",
bordered:
"bg-transparent border-2 border-zinc-200 dark:border-zinc-800 focus-within:border-sky-500 text-zinc-900 dark:text-zinc-100",
flat: "bg-zinc-100 dark:bg-zinc-800/60 border-transparent hover:bg-zinc-200/70 dark:hover:bg-zinc-800 focus-within:bg-white dark:focus-within:bg-zinc-900 focus-within:border-sky-500 border text-zinc-900 dark:text-zinc-100",
underlined:
"bg-transparent border-b-2 border-zinc-200 dark:border-zinc-800 rounded-none px-0 focus-within:border-sky-500 text-zinc-900 dark:text-zinc-100",
filled:
"bg-zinc-100 dark:bg-zinc-800/80 border border-transparent focus-within:border-sky-500 focus-within:ring-2 focus-within:ring-sky-500/40 text-zinc-900 dark:text-zinc-100",
glassmorphism:
"backdrop-blur-md bg-white/10 dark:bg-black/10 border border-white/20 dark:border-white/10 focus-within:border-sky-500 shadow-lg text-zinc-900 dark:text-zinc-100",
"gradient-border":
"bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 relative [background-clip:padding-box] border border-transparent before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:p-[1px] before:bg-gradient-to-r before:from-sky-500 before:via-indigo-500 before:to-pink-500 focus-within:ring-2 focus-within:ring-indigo-500/30",
glow: "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-xs focus-within:border-sky-500 focus-within:shadow-[0_0_12px_rgba(14,165,233,0.35)] text-zinc-900 dark:text-zinc-100",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface MultiSelectOption {
label: string;
value: string;
category?: string;
icon?: React.ReactNode;
}
interface MultiSelectProps extends VariantProps<typeof multiSelectVariants> {
options: MultiSelectOption[];
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
label?: React.ReactNode;
maxCount?: number;
showSelectAll?: boolean;
isDisabled?: boolean;
isInvalid?: boolean;
className?: string;
isRequired?: boolean;
}
export function MultiSelect({
options,
value,
onChange,
placeholder = "Select options...",
label,
maxCount,
showSelectAll = true,
isDisabled = false,
isInvalid = false,
variant,
className,
isRequired = false,
}: MultiSelectProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const [collapsedCategories, setCollapsedCategories] = React.useState<
Record<string, boolean>
>({});
const containerRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearch("");
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const toggleOption = (optionValue: string) => {
if (value.includes(optionValue)) {
onChange(value.filter((v) => v !== optionValue));
} else {
if (maxCount && value.length >= maxCount) return;
onChange([...value, optionValue]);
}
};
const removeOption = (optionValue: string) => {
onChange(value.filter((v) => v !== optionValue));
};
const selectAll = () => {
const availableValues = filteredOptions.map((opt) => opt.value);
const newValues = Array.from(new Set([...value, ...availableValues]));
onChange(maxCount ? newValues.slice(0, maxCount) : newValues);
};
const deselectAll = () => {
onChange([]);
};
const toggleCategoryCollapse = (category: string) => {
setCollapsedCategories((prev) => ({
...prev,
[category]: !prev[category],
}));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Backspace" && search === "" && value.length > 0) {
onChange(value.slice(0, -1));
}
if (e.key === "Escape") {
setIsOpen(false);
setSearch("");
}
};
const filteredOptions = options.filter(
(opt) =>
opt.label.toLowerCase().includes(search.toLowerCase()) ||
opt.category?.toLowerCase().includes(search.toLowerCase()),
);
const selectedOptions = options.filter((opt) => value.includes(opt.value));
const groupedOptions = React.useMemo(() => {
const groups: Record<string, MultiSelectOption[]> = {};
const ungrouped: MultiSelectOption[] = [];
filteredOptions.forEach((opt) => {
if (opt.category) {
if (!groups[opt.category]) groups[opt.category] = [];
groups[opt.category].push(opt);
} else {
ungrouped.push(opt);
}
});
return { groups, ungrouped };
}, [filteredOptions]);
const isAllSelected =
filteredOptions.length > 0 &&
filteredOptions.every((opt) => value.includes(opt.value));
return (
<div className={cn("relative w-full", className)} ref={containerRef}>
{label && (
<label className="block text-sm font-bold text-zinc-700 dark:text-zinc-300 mb-1.5">
{label}
{isRequired && <span className="text-rose-500 ml-0.5">*</span>}
</label>
)}
<div
className={cn(
multiSelectVariants({ variant }),
isInvalid
? "border-rose-500 focus-within:ring-2 focus-within:ring-rose-500/20"
: "",
isDisabled && "opacity-50 cursor-not-allowed",
)}
onClick={() => {
if (!isDisabled) {
setIsOpen(true);
inputRef.current?.focus();
}
}}
>
{selectedOptions.map((opt) => (
<span
key={opt.value}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
>
{opt.icon && <span className="shrink-0">{opt.icon}</span>}
{opt.label}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeOption(opt.value);
}}
className="ml-0.5 rounded-full hover:bg-zinc-200 dark:hover:bg-zinc-700 p-0.5 transition-colors"
disabled={isDisabled}
>
<Icon icon="hugeicons:cancel-01" className="size-3" />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => !isDisabled && setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
disabled={isDisabled}
className="flex-1 min-w-[80px] bg-transparent text-sm text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400 outline-none disabled:cursor-not-allowed"
/>
</div>
{isOpen && !isDisabled && (
<div className="absolute z-50 mt-2 w-full max-h-72 overflow-y-auto rounded-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 shadow-xl p-1 space-y-1">
{showSelectAll && filteredOptions.length > 0 && (
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-800/30 rounded-xl mb-1">
<span className="text-xs text-zinc-500 font-medium">
{value.length} selected
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={isAllSelected ? deselectAll : selectAll}
className="text-xs font-semibold text-sky-500 hover:text-sky-600 transition-colors"
>
{isAllSelected ? "Deselect All" : "Select All"}
</button>
{value.length > 0 && !isAllSelected && (
<button
type="button"
onClick={deselectAll}
className="text-xs text-rose-500 hover:underline"
>
Clear
</button>
)}
</div>
</div>
)}
{filteredOptions.length === 0 ? (
<div className="px-4 py-3 text-sm text-zinc-400">
No options found.
</div>
) : (
<>
{Object.entries(groupedOptions.groups).map(
([categoryName, groupOpts]) => {
const isCollapsed = collapsedCategories[categoryName];
const categorySelectedCount = groupOpts.filter((opt) =>
value.includes(opt.value),
).length;
return (
<div key={categoryName} className="space-y-0.5">
<button
type="button"
onClick={() => toggleCategoryCollapse(categoryName)}
className="w-full flex items-center justify-between px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800/60 rounded-xl transition-colors select-none"
>
<span className="flex items-center gap-1.5">
<Icon
icon="hugeicons:arrow-down-01"
className={cn(
"size-3 transition-transform duration-200",
isCollapsed && "-rotate-90",
)}
/>
{categoryName}
</span>
{categorySelectedCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full text-[10px] bg-sky-500/10 text-sky-500 font-semibold">
{categorySelectedCount}/{groupOpts.length}
</span>
)}
</button>
{!isCollapsed &&
groupOpts.map((opt) => renderOptionItem(opt))}
</div>
);
},
)}
{groupedOptions.ungrouped.map((opt) => renderOptionItem(opt))}
</>
)}
</div>
)}
</div>
);
function renderOptionItem(opt: MultiSelectOption) {
const isSelected = value.includes(opt.value);
const isLimitReached = maxCount
? value.length >= maxCount && !isSelected
: false;
return (
<button
key={opt.value}
type="button"
onClick={() => toggleOption(opt.value)}
disabled={isLimitReached}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 text-sm text-left transition-colors rounded-xl",
isSelected
? "text-sky-500 bg-sky-500/5 font-medium"
: "text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800/50",
isLimitReached && "opacity-40 cursor-not-allowed",
)}
>
<span
className={cn(
"flex items-center justify-center size-4 rounded border transition-colors",
isSelected
? "bg-sky-500 border-sky-500 text-white"
: "border-zinc-300 dark:border-zinc-600",
)}
>
{isSelected && (
<Icon icon="hugeicons:checkmark-circle-02" className="size-3" />
)}
</span>
{opt.icon && <span className="shrink-0">{opt.icon}</span>}
<span className="flex-1">{opt.label}</span>
</button>
);
}
}
Default
A multi-select input with pre-selected items and search filtering.
AppleCherry
Variants
Defines the visual appearance of the multi-select input using the variant prop.
variant:
default | bordered | flat | filled | glow | glassmorphism | gradient-border | underlinedApple
Banana
Cherry
Dragon Fruit
With Icons
Options can include icons for richer visual context.
options: anyreact: anyicon: anydevicon: anysize-4: anytypescript: any
ReactTypeScript
Max Count
Limit the number of selectable items with the maxCount prop.
options: anyfruitOptions: anysetValue: anyup: anyto: anyfruits: anymaxCount: any
Apple
Select All Batch Actions & Collapsible Category Headers
Batch select or clear all filtered options with showSelectAll, and group items into collapsible category sections via the category option field.
showSelectAll: booleancategory?: string (in option)
ReactNode.jsPostgreSQL
Required State
Displays an asterisk next to the label indicating that choosing options is mandatory.
isRequired: boolean
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| showSelectAll | boolean | true | Renders batch action bar for Select All and Clear. |
| options | MultiSelectOption[] | — | Array of selectable options (with optional category & icon). |
| value | string[] | — | Currently selected values. |
| onChange | (value: string[]) => void | — | Callback when selection changes. |