Multi Select

Popover-based multi-selection input with selected-value badges and optional search.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/multi-select.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

3 stories available:

Code

"use client"; import * as React from "react"; import { Check, ChevronDown } from "lucide-react"; import { cn } from "../../lib/utils"; import { Badge } from "../badge/badge"; import { Button } from "../button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "../command"; import { Popover, PopoverContent, PopoverTrigger } from "../popover"; export type MultiSelectOption = { disabled?: boolean; label: string; value: string; }; export type MultiSelectProps = Omit< React.ButtonHTMLAttributes<HTMLButtonElement>, "defaultValue" | "onChange" | "value" > & { defaultValue?: string[]; emptyText?: string; onOpenChange?: (open: boolean) => void; onValueChange?: (value: string[]) => void; options: MultiSelectOption[]; placeholder?: string; searchable?: boolean; searchPlaceholder?: string; value?: string[]; }; type TriggerContentProps = { placeholder: string; selectedOptions: MultiSelectOption[]; }; type OptionListProps = { disabled: boolean; emptyText: string; onSelect: (value: string) => void; options: MultiSelectOption[]; searchable: boolean; searchPlaceholder: string; selectedValues: string[]; }; type MultiSelectStateOptions = { defaultValue: string[]; onOpenChange?: (open: boolean) => void; onValueChange?: (value: string[]) => void; value?: string[]; }; type MultiSelectTriggerProps = Omit< MultiSelectProps, | "defaultValue" | "emptyText" | "onOpenChange" | "onValueChange" | "options" | "searchable" | "searchPlaceholder" | "value" > & { contentId: string; open: boolean; selectedOptions: MultiSelectOption[]; }; type MultiSelectContentProps = { contentId: string; disabled: boolean; emptyText: string; onSelect: (value: string) => void; options: MultiSelectOption[]; searchable: boolean; searchPlaceholder: string; selectedValues: string[]; }; function getUniqueValues(values: string[]) { return values.filter((value, index) => values.indexOf(value) === index); } function shouldOpenFromKey(key: string) { return key === " " || key === "ArrowDown" || key === "Enter"; } function TriggerContent({ placeholder, selectedOptions }: TriggerContentProps) { if (selectedOptions.length === 0) { return <span>{placeholder}</span>; } return ( <> {selectedOptions.map((option) => ( <Badge className="max-w-full" key={option.value} variant="secondary"> <span className="truncate">{option.label}</span> </Badge> ))} </> ); } function OptionList({ disabled, emptyText, onSelect, options, searchable, searchPlaceholder, selectedValues, }: OptionListProps) { return ( <Command> {searchable ? <CommandInput placeholder={searchPlaceholder} /> : null} <CommandList aria-multiselectable="true"> <CommandEmpty>{emptyText}</CommandEmpty> <CommandGroup> <div> {options.map((option) => { const isSelected = selectedValues.includes(option.value); return ( <CommandItem aria-disabled={option.disabled || undefined} aria-selected={isSelected} className="gap-2" disabled={disabled || option.disabled} key={option.value} onSelect={() => { onSelect(option.value); }} role="option" value={option.label} > <span className={cn( "flex size-4 items-center justify-center rounded-sm border border-input bg-background text-primary transition-opacity", isSelected ? "opacity-100" : "opacity-50", )} > {isSelected ? <Check className="size-3.5" /> : null} </span> <span className="flex-1">{option.label}</span> </CommandItem> ); })} </div> </CommandGroup> </CommandList> </Command> ); } function MultiSelectContent({ contentId, disabled, emptyText, onSelect, options, searchable, searchPlaceholder, selectedValues, }: MultiSelectContentProps) { return ( <PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0" id={contentId} > <OptionList disabled={disabled} emptyText={emptyText} onSelect={onSelect} options={options} searchable={searchable} searchPlaceholder={searchPlaceholder} selectedValues={selectedValues} /> </PopoverContent> ); } function useMultiSelectState({ defaultValue, onOpenChange, onValueChange, value, }: MultiSelectStateOptions) { const [open, setOpen] = React.useState(false); const [uncontrolledValue, setUncontrolledValue] = React.useState(() => getUniqueValues(defaultValue), ); const isControlled = value !== undefined; const selectedValues = React.useMemo( () => getUniqueValues(value ?? uncontrolledValue), [uncontrolledValue, value], ); const setSelectedValues = React.useCallback( (nextValue: string[]) => { const uniqueValues = getUniqueValues(nextValue); if (!isControlled) { setUncontrolledValue(uniqueValues); } onValueChange?.(uniqueValues); }, [isControlled, onValueChange], ); const handleOpenChange = React.useCallback( (nextOpen: boolean) => { setOpen(nextOpen); onOpenChange?.(nextOpen); }, [onOpenChange], ); return { handleOpenChange, open, selectedValues, setSelectedValues, }; } const MultiSelectTrigger = React.forwardRef< HTMLButtonElement, MultiSelectTriggerProps >( ( { className, contentId, disabled = false, onKeyDown, open, placeholder = "Select options", selectedOptions, ...props }, ref, ) => ( <Button aria-controls={contentId} aria-expanded={open} aria-haspopup="listbox" className={cn( "min-h-10 w-full justify-between px-3 py-2 text-sm font-normal", selectedOptions.length === 0 && "text-muted-foreground", className, )} disabled={disabled} onKeyDown={onKeyDown} ref={ref} role="combobox" type="button" variant="outline" {...props} > <span className="flex min-w-0 flex-1 flex-wrap items-center gap-1 text-left"> <TriggerContent placeholder={placeholder} selectedOptions={selectedOptions} /> </span> <ChevronDown className="ml-2 size-4 shrink-0 opacity-50" /> </Button> ), ); MultiSelectTrigger.displayName = "MultiSelectTrigger"; const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>( ( { defaultValue = [], emptyText = "No options found.", onKeyDown, onOpenChange, onValueChange, options, searchable = false, searchPlaceholder = "Search options...", value, ...props }, ref, ) => { const contentId = React.useId(); const { handleOpenChange, open, selectedValues, setSelectedValues } = useMultiSelectState({ defaultValue, onOpenChange, onValueChange, value }); const selectedOptions = React.useMemo( () => options.filter((option) => selectedValues.includes(option.value)), [options, selectedValues], ); const handleSelect = React.useCallback( (nextValue: string) => { const nextSelectedValues = selectedValues.includes(nextValue) ? selectedValues.filter((valueItem) => valueItem !== nextValue) : [...selectedValues, nextValue]; setSelectedValues(nextSelectedValues); }, [selectedValues, setSelectedValues], ); const handleTriggerKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLButtonElement>) => { onKeyDown?.(event); if (event.defaultPrevented || props.disabled) { return; } if (shouldOpenFromKey(event.key)) { event.preventDefault(); handleOpenChange(true); } }, [handleOpenChange, onKeyDown, props.disabled], ); return ( <Popover onOpenChange={handleOpenChange} open={open}> <PopoverTrigger asChild> <MultiSelectTrigger {...props} contentId={contentId} onKeyDown={handleTriggerKeyDown} open={open} ref={ref} selectedOptions={selectedOptions} /> </PopoverTrigger> <MultiSelectContent contentId={contentId} disabled={props.disabled || false} emptyText={emptyText} onSelect={handleSelect} options={options} searchable={searchable} searchPlaceholder={searchPlaceholder} selectedValues={selectedValues} /> </Popover> ); }, ); MultiSelect.displayName = "MultiSelect"; export { MultiSelect };

Dependencies

  • @vllnt/ui@^0.2.1