Multi Select

Popover-based multi-selection input with selected-value badges and optional search. Install Multi Select from the VLLNT UI registry with the shadcn CLI.

Report a bug

Popover-based multi-selection input with selected-value badges and optional search. Part of the form family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

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

Installation

Add Multi Select to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

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

Source

"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/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "../command/command"; import { Popover, PopoverContent, PopoverTrigger } from "../popover/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 = ({ className, contentId, disabled = false, onKeyDown, open, placeholder = "Select options", ref, selectedOptions, ...props }: MultiSelectTriggerProps & { ref?: React.Ref<HTMLButtonElement> }) => ( <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 = ({ defaultValue = [], emptyText = "No options found.", onKeyDown, onOpenChange, onValueChange, options, ref, searchable = false, searchPlaceholder = "Search options...", value, ...props }: MultiSelectProps & { ref?: React.Ref<HTMLButtonElement> }) => { 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 };

Stories

Explore every variant and state in the interactive Storybook:

Preview

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

Dependencies

  • @vllnt/ui@^0.3.0