Multiple Selector

A searchable, multi-select dropdown component with support for grouping, creation, and asynchronous data loading.

tsx
import { MultipleSelector } from "@/components/ui/multiple-selector"
export function MultipleSelectorDemo() {
const [value, setValue] = React.useState<Option[]>([])
const options = [
{ value: "next", label: "Next.js" },
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
{ value: "angular", label: "Angular" },
]
return (
<MultipleSelector
value={value}
onChange={setValue}
options={options}
placeholder="Select frameworks..."
/>
)
}

Installation

CLI

bash
npx fivui add multiple-selector

Manual

Install the following dependencies:

bash
npm install lucide-react

Copy and paste the following code into your project:

tsx
"use client"
import * as React from "react"
import { X, ChevronDown, Loader2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
export interface Option {
value: string
label: string
fixed?: boolean
disabled?: boolean
group?: string
}
export interface MultipleSelectorProps {
value?: Option[]
defaultValue?: Option[]
options?: Option[]
placeholder?: string
disabled?: boolean
onChange?: (options: Option[]) => void
onSearch?: (value: string) => Promise<Option[]>
creatable?: boolean
maxSelected?: number
onMaxSelected?: (maxLimit: number) => void
className?: string
badgeClassName?: string
loadingIndicator?: React.ReactNode
emptyIndicator?: React.ReactNode
searchDelay?: number
hidePlaceholderWhenSelected?: boolean
}
// Custom hook for debounced search
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value)
React.useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
export const MultipleSelector = React.forwardRef<HTMLDivElement, MultipleSelectorProps>(
(
{
value,
defaultValue = [],
options = [],
placeholder = "Select options...",
disabled,
onChange,
onSearch,
creatable = false,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
className,
badgeClassName,
loadingIndicator,
emptyIndicator,
searchDelay = 300,
hidePlaceholderWhenSelected = false,
},
ref
) => {
const [searchValue, setSearchValue] = React.useState("")
const [isOpen, setIsOpen] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(false)
const containerRef = React.useRef<HTMLDivElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const dropdownRef = React.useRef<HTMLDivElement>(null)
// Use controlled value if provided, otherwise use internal state
const [internalSelectedOptions, setInternalSelectedOptions] = React.useState<Option[]>(defaultValue)
const selectedOptions = value !== undefined ? value : internalSelectedOptions
// Use provided options or internal state for available options
const [internalAvailableOptions, setInternalAvailableOptions] = React.useState(options)
const availableOptions = onSearch ? internalAvailableOptions : options
// Debounced search value for async search
const debouncedSearchValue = useDebounce(searchValue, searchDelay)
// Handle debounced async search
React.useEffect(() => {
if (!onSearch || !debouncedSearchValue.trim()) {
setIsLoading(false)
return
}
const performSearch = async () => {
setIsLoading(true)
try {
const results = await onSearch(debouncedSearchValue)
setInternalAvailableOptions(results)
} catch (error) {
console.error('Search error:', error)
setInternalAvailableOptions([])
} finally {
setIsLoading(false)
}
}
performSearch()
}, [debouncedSearchValue, onSearch])
// Handle clicks outside to close dropdown
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node) &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const handleSelect = React.useCallback((option: Option) => {
if (selectedOptions.length >= maxSelected) {
onMaxSelected?.(maxSelected)
return
}
const newSelected = [...selectedOptions, option]
if (value === undefined) {
setInternalSelectedOptions(newSelected)
}
onChange?.(newSelected)
setSearchValue("")
setIsOpen(false)
// Focus back on input after selection
setTimeout(() => inputRef.current?.focus(), 0)
}, [selectedOptions, maxSelected, onMaxSelected, onChange, value])
const handleRemove = React.useCallback((optionToRemove: Option) => {
if (optionToRemove.fixed) return
const newSelected = selectedOptions.filter(
option => option.value !== optionToRemove.value
)
if (value === undefined) {
setInternalSelectedOptions(newSelected)
}
onChange?.(newSelected)
}, [selectedOptions, onChange, value])
const handleSearch = React.useCallback((value: string) => {
setSearchValue(value)
setIsOpen(true)
// For non-async search, filter immediately
if (!onSearch && value.trim()) {
setIsLoading(false)
}
}, [onSearch])
const handleCreateOption = React.useCallback(() => {
if (!searchValue.trim()) return
const newOption: Option = {
value: searchValue.toLowerCase(),
label: searchValue.trim(),
}
handleSelect(newOption)
}, [searchValue, handleSelect])
const handleInputFocus = () => {
setIsOpen(true)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
setIsOpen(false)
inputRef.current?.blur()
}
}
// Filter and group options
const filteredOptions = React.useMemo(() => {
return availableOptions.filter(option =>
!selectedOptions.some(selected => selected.value === option.value) &&
(searchValue === "" || option.label.toLowerCase().includes(searchValue.toLowerCase()))
)
}, [availableOptions, selectedOptions, searchValue])
const groupedOptions = React.useMemo(() => {
const groups: Record<string, Option[]> = {}
filteredOptions.forEach(option => {
const group = option.group || "Options"
if (!groups[group]) {
groups[group] = []
}
groups[group].push(option)
})
return groups
}, [filteredOptions])
const hasOptions = Object.keys(groupedOptions).length > 0
const showCreateOption = creatable && searchValue.trim() && !filteredOptions.some(opt => opt.label.toLowerCase() === searchValue.toLowerCase())
// Default loading indicator
const defaultLoadingIndicator = (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">Searching...</span>
</div>
)
// Default empty indicator
const defaultEmptyIndicator = (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
No options available
</div>
)
return (
<div ref={ref} className="relative">
<div
ref={containerRef}
className={cn(
"flex min-h-10 w-full flex-wrap items-center gap-1 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background",
"focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
disabled && "cursor-not-allowed opacity-50",
className
)}
onClick={() => {
if (!disabled) {
inputRef.current?.focus()
setIsOpen(true)
}
}}
>
{selectedOptions.map((option) => (
<Badge
key={option.value}
variant="secondary"
className={cn(
"gap-1 pr-0.5",
option.fixed && "bg-muted hover:bg-muted",
option.disabled && "bg-muted-foreground text-muted hover:bg-muted-foreground",
badgeClassName
)}
>
<span className="text-xs">{option.label}</span>
{!option.fixed && (
<button
type="button"
className="ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleRemove(option)
}}
disabled={disabled}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
)}
</Badge>
))}
<Input
ref={inputRef}
value={searchValue}
onChange={(e) => handleSearch(e.target.value)}
onFocus={handleInputFocus}
onKeyDown={handleKeyDown}
className="h-6 flex-1 border-0 bg-transparent p-0 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
disabled={disabled}
placeholder={
hidePlaceholderWhenSelected && selectedOptions.length > 0
? ""
: selectedOptions.length === 0
? placeholder
: ""
}
/>
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
{isOpen && (
<div
ref={dropdownRef}
className="absolute top-full z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
>
{isLoading ? (
loadingIndicator || defaultLoadingIndicator
) : hasOptions ? (
Object.entries(groupedOptions).map(([group, options]) => (
<div key={group}>
{group !== "Options" && (
<div className="px-2 py-1.5 text-sm font-semibold text-muted-foreground">
{group}
</div>
)}
{options.map((option) => (
<div
key={option.value}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
"hover:bg-accent hover:text-accent-foreground",
option.disabled && "pointer-events-none opacity-50"
)}
onClick={() => !option.disabled && handleSelect(option)}
>
{option.label}
</div>
))}
</div>
))
) : showCreateOption ? (
<div
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
onClick={handleCreateOption}
>
Create "{searchValue}"
</div>
) : (
emptyIndicator || defaultEmptyIndicator
)}
{hasOptions && showCreateOption && (
<div
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
onClick={handleCreateOption}
>
Create "{searchValue}"
</div>
)}
</div>
)}
</div>
)
}
)
MultipleSelector.displayName = "MultipleSelector"

Usage

tsx
import { MultipleSelector } from "@/components/ui/multiple-selector"
tsx
const [value, setValue] = React.useState<Option[]>([])
const options = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "angular", label: "Angular" },
]
return (
<MultipleSelector
value={value}
onChange={setValue}
options={options}
placeholder="Select frameworks..."
/>
)

Examples

Async Search

tsx
export function MultipleSelectorAsyncDemo() {
const [value, setValue] = React.useState<Option[]>([])
const onSearch = React.useCallback(async (searchValue: string) => {
// Simulated async search with longer delay to show loading
await new Promise(resolve => setTimeout(resolve, 1500))
const frameworks = [
"Next.js", "React", "Vue", "Angular", "Svelte",
"Nuxt", "Remix", "Solid", "Qwik", "Astro"
]
return frameworks
.filter(framework =>
framework.toLowerCase().includes(searchValue.toLowerCase())
)
.map(framework => ({
value: framework.toLowerCase(),
label: framework
}))
}, [])
return (
<MultipleSelector
value={value}
onChange={setValue}
onSearch={onSearch}
placeholder="Search frameworks..."
searchDelay={500}
loadingIndicator={
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary mr-2"></div>
<span className="text-sm text-muted-foreground">Loading frameworks...</span>
</div>
}
emptyIndicator={
<div className="text-center py-6">
<p className="text-sm text-muted-foreground">No frameworks found</p>
<p className="text-xs text-muted-foreground mt-1">Try searching for "react", "vue", or "angular"</p>
</div>
}
/>
)
}

Grouped

tsx
export function MultipleSelectorGroupDemo() {
const [value, setValue] = React.useState<Option[]>([])
const options = [
{ value: "next", label: "Next.js", group: "React" },
{ value: "vite", label: "Vite", group: "React" },
{ value: "nuxt", label: "Nuxt.js", group: "Vue" },
{ value: "vitepress", label: "VitePress", group: "Vue" },
{ value: "sveltekit", label: "SvelteKit", group: "Svelte" },
{ value: "astro", label: "Astro", group: "Other" },
]
return (
<MultipleSelector
value={value}
onChange={setValue}
options={options}
placeholder="Select frameworks..."
/>
)
}

Debounced Search

Search API calls made: 0 (debounced with 800ms delay)

tsx
export function MultipleSelectorDebouncedDemo() {
const [value, setValue] = React.useState<Option[]>([])
const [searchCount, setSearchCount] = React.useState(0)
const onSearch = React.useCallback(async (searchValue: string) => {
setSearchCount(prev => prev + 1)
// Simulated API call
await new Promise(resolve => setTimeout(resolve, 800))
const allOptions = [
"React", "Vue", "Angular", "Svelte", "Next.js", "Nuxt.js",
"Remix", "SvelteKit", "Vite", "Webpack", "Parcel", "Rollup",
"TypeScript", "JavaScript", "Tailwind CSS", "Bootstrap"
]
return allOptions
.filter(option =>
option.toLowerCase().includes(searchValue.toLowerCase())
)
.map(option => ({
value: option.toLowerCase().replace(/s+/g, '-'),
label: option
}))
}, [])
return (
<div className="space-y-2">
<MultipleSelector
value={value}
onChange={setValue}
onSearch={onSearch}
placeholder="Type to search with debounce..."
searchDelay={800}
hidePlaceholderWhenSelected
loadingIndicator={
<div className="flex items-center justify-center py-4">
<div className="animate-pulse flex space-x-2">
<div className="rounded-full bg-muted h-2 w-2"></div>
<div className="rounded-full bg-muted h-2 w-2"></div>
<div className="rounded-full bg-muted h-2 w-2"></div>
</div>
<span className="ml-2 text-sm text-muted-foreground">Searching...</span>
</div>
}
emptyIndicator={
<div className="text-center py-4">
<p className="text-sm text-muted-foreground">No matches found</p>
</div>
}
/>
<p className="text-xs text-muted-foreground">
Search API calls made: {searchCount} (debounced with 800ms delay)
</p>
</div>
)
}

Creatable

tsx
export function MultipleSelectorCreatableDemo() {
const [value, setValue] = React.useState<Option[]>([])
const options = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "svelte", label: "Svelte" },
]
return (
<MultipleSelector
value={value}
onChange={setValue}
options={options}
placeholder="Select or create frameworks..."
creatable
emptyIndicator={
<div className="text-center py-4">
<p className="text-sm text-muted-foreground">No frameworks found</p>
<p className="text-xs text-muted-foreground mt-1">Type to create a new one!</p>
</div>
}
/>
)
}

API Reference

MultipleSelector

PropTypeDefaultDescription
valueOption[]-The controlled value of the selected options
defaultValueOption[][]The default selected options when uncontrolled
optionsOption[][]The available options to display in the dropdown
placeholderstring"Select options..."The placeholder text to display when no options are selected
disabledbooleanfalseWhen true, prevents user interaction with the selector
onChange(options: Option[]) => void-Event handler called when the selected options change
onSearch(value: string) => Promise<Option[]>-Async function to fetch options based on search input
creatablebooleanfalseWhen true, allows creating new options from input value
maxSelectednumberNumber.MAX_SAFE_INTEGERMaximum number of options that can be selected
onMaxSelected(maxLimit: number) => void-Called when the maximum selection limit is reached
searchDelaynumber300Debounce delay for search in milliseconds
loadingIndicatorReactNode-Custom loading indicator when fetching options
emptyIndicatorReactNode-Custom empty state when no options are found
hidePlaceholderWhenSelectedbooleanfalseHide the placeholder when options are selected
classNamestring-Additional CSS classes for the root element
badgeClassNamestring-Additional CSS classes for the selected option badges

Option

PropertyTypeDescription
valuestringThe unique identifier for the option
labelstringThe display text for the option
fixedbooleanWhen true, prevents the option from being removed
disabledbooleanWhen true, prevents the option from being selected
groupstringOptional group name for organizing options