Multi-Select
A multi-select input that emulates the Shadcn Select component as closely as possible.
import { MultiSelect, MultiSelectContent, MultiSelectGroup, MultiSelectItem, MultiSelectTrigger, MultiSelectValue,} from "@/components/ui/multi-select"
export function BasicMultiSelect() { return ( <MultiSelect> <MultiSelectTrigger className="w-full max-w-[400px]"> <MultiSelectValue placeholder="Select frameworks..." /> </MultiSelectTrigger> <MultiSelectContent> {/* Items must be wrapped in a group for proper styling */} <MultiSelectGroup> <MultiSelectItem value="next.js">Next.js</MultiSelectItem> <MultiSelectItem value="sveltekit">SvelteKit</MultiSelectItem> <MultiSelectItem value="nuxt.js">Nuxt.js</MultiSelectItem> <MultiSelectItem value="remix">Remix</MultiSelectItem> <MultiSelectItem value="astro">Astro</MultiSelectItem> <MultiSelectItem value="vue">Vue.js</MultiSelectItem> <MultiSelectItem value="react">React</MultiSelectItem> </MultiSelectGroup> </MultiSelectContent> </MultiSelect> )}
Installation
Section titled “Installation”Copy and paste the following code into your project.
components/ui/multi-select.tsx
"use client"
import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react"import { cn } from "@/lib/utils"import { Button } from "@/components/ui/button"import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator,} from "@/components/ui/command"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, type ComponentPropsWithoutRef, type ReactNode,} from "react"import { Badge } from "@/components/ui/badge"
type MultiSelectContextType = { open: boolean setOpen: (open: boolean) => void selectedValues: Set<string> toggleValue: (value: string) => void items: Map<string, ReactNode> onItemAdded: (value: string, label: ReactNode) => void}const MultiSelectContext = createContext<MultiSelectContextType | null>(null)
export function MultiSelect({ children, values, defaultValues, onValuesChange,}: { children: ReactNode values?: string[] defaultValues?: string[] onValuesChange?: (values: string[]) => void}) { const [open, setOpen] = useState(false) const [selectedValues, setSelectedValues] = useState( new Set<string>(values ?? defaultValues), ) const [items, setItems] = useState<Map<string, ReactNode>>(new Map())
function toggleValue(value: string) { setSelectedValues(prev => { const newSet = new Set(prev) if (newSet.has(value)) { newSet.delete(value) } else { newSet.add(value) }
onValuesChange?.([...newSet]) return newSet }) }
const onItemAdded = useCallback((value: string, label: ReactNode) => { setItems(prev => { if (prev.get(value) === label) return prev return new Map(prev).set(value, label) }) }, [])
return ( <MultiSelectContext value={{ open, setOpen, selectedValues: values ? new Set(values) : selectedValues, toggleValue, items, onItemAdded, }} > <Popover open={open} onOpenChange={setOpen}> {children} </Popover> </MultiSelectContext> )}
export function MultiSelectTrigger({ className, children, ...props}: { className?: string children?: ReactNode} & ComponentPropsWithoutRef<typeof Button>) { const { open } = useMultiSelectContext()
return ( <PopoverTrigger asChild> <Button {...props} variant={props.variant ?? "outline"} role={props.role ?? "combobox"} aria-expanded={props["aria-expanded"] ?? open} className={cn( "flex h-auto min-h-9 w-fit items-center justify-between gap-2 overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className, )} > {children} <ChevronsUpDownIcon className="size-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> )}
export function MultiSelectValue({ placeholder, clickToRemove = true, className, overflowBehavior = "wrap-when-open", overflowContent = (overflowAmount: number) => `+${overflowAmount}`, ...props}: { placeholder?: string clickToRemove?: boolean overflowBehavior?: "wrap" | "wrap-when-open" | "cutoff" overflowContent?: (overflowAmount: number) => ReactNode} & Omit<ComponentPropsWithoutRef<"div">, "children">) { const { selectedValues, toggleValue, items, open } = useMultiSelectContext() const [overflowAmount, setOverflowAmount] = useState(0) const valueRef = useRef<HTMLDivElement>(null) const overflowRef = useRef<HTMLDivElement>(null) const itemsRef = useRef<Set<HTMLElement>>(new Set())
const shouldWrap = overflowBehavior === "wrap" || (overflowBehavior === "wrap-when-open" && open)
const checkOverflow = useCallback(() => { if (valueRef.current == null) return
const containerElement = valueRef.current const overflowElement = overflowRef.current
if (overflowElement != null) overflowElement.style.display = "none" itemsRef.current.forEach(child => child.style.removeProperty("display")) let amount = 0 for (let i = itemsRef.current.size - 1; i >= 0; i--) { const child = [...itemsRef.current][i] if (containerElement.scrollWidth <= containerElement.clientWidth) { break } amount = itemsRef.current.size - i child.style.display = "none" overflowElement?.style.removeProperty("display") } setOverflowAmount(amount) }, [])
useEffect(() => { if (valueRef.current == null) return
const observer = new ResizeObserver(checkOverflow) observer.observe(valueRef.current)
return () => observer.disconnect() }, [checkOverflow])
useLayoutEffect(() => { checkOverflow() }, [selectedValues, open, checkOverflow])
if (selectedValues.size === 0 && placeholder) { return ( <span className="font-normal text-muted-foreground">{placeholder}</span> ) }
return ( <div {...props} ref={valueRef} className={cn( "flex w-full gap-1.5 overflow-hidden", shouldWrap && "h-full flex-wrap", className, )} > {[...selectedValues] .filter(value => items.has(value)) .map(value => ( <Badge ref={el => { if (el == null) return
itemsRef.current.add(el) return () => { itemsRef.current.delete(el) } }} variant="outline" className="group flex items-center gap-1" key={value} onClick={ clickToRemove ? e => { e.stopPropagation() toggleValue(value) } : undefined } > {items.get(value)} {clickToRemove && ( <XIcon className="size-2 text-muted-foreground group-hover:text-destructive" /> )} </Badge> ))} <Badge style={{ display: overflowAmount > 0 && !shouldWrap ? "block" : "none", }} variant="outline" ref={overflowRef} > {overflowContent(overflowAmount)} </Badge> </div> )}
export function MultiSelectContent({ search = true, children, ...props}: { search?: boolean | { placeholder?: string; emptyMessage?: string } children: ReactNode} & Omit<ComponentPropsWithoutRef<typeof Command>, "children">) { const canSearch = typeof search === "object" ? true : search
return ( <> <div style={{ display: "none" }}> <Command> <CommandList>{children}</CommandList> </Command> </div> <PopoverContent className="min-w-[var(--radix-popover-trigger-width)] p-0"> <Command {...props}> {canSearch ? ( <CommandInput placeholder={ typeof search === "object" ? search.placeholder : undefined } /> ) : ( <button autoFocus aria-hidden="true" className="sr-only" /> )} <CommandList> {canSearch && ( <CommandEmpty> {typeof search === "object" ? search.emptyMessage : undefined} </CommandEmpty> )} {children} </CommandList> </Command> </PopoverContent> </> )}
export function MultiSelectItem({ value, children, badgeLabel, onSelect, ...props}: { badgeLabel?: ReactNode value: string} & Omit<ComponentPropsWithoutRef<typeof CommandItem>, "value">) { const { toggleValue, selectedValues, onItemAdded } = useMultiSelectContext() const isSelected = selectedValues.has(value)
useEffect(() => { onItemAdded(value, badgeLabel ?? children) }, [value, children, onItemAdded, badgeLabel])
return ( <CommandItem {...props} value={value} onSelect={v => { toggleValue(v) onSelect?.(v) }} > <CheckIcon className={cn("mr-2 size-4", isSelected ? "opacity-100" : "opacity-0")} /> {children} </CommandItem> )}
export function MultiSelectGroup( props: ComponentPropsWithoutRef<typeof CommandGroup>,) { return <CommandGroup {...props} />}
export function MultiSelectSeparator( props: ComponentPropsWithoutRef<typeof CommandSeparator>,) { return <CommandSeparator {...props} />}
function useMultiSelectContext() { const context = useContext(MultiSelectContext) if (context == null) { throw new Error( "useMultiSelectContext must be used within a MultiSelectContext", ) } return context}
Update the import paths to match your project setup.
import { MultiSelect, MultiSelectContent, MultiSelectGroup, MultiSelectItem, MultiSelectTrigger, MultiSelectValue,} from "@/components/ui/multi-select"
<MultiSelect> <MultiSelectTrigger className="w-full max-w-[400px]"> <MultiSelectValue placeholder="Select frameworks..." /> </MultiSelectTrigger> <MultiSelectContent> <MultiSelectGroup> <MultiSelectItem value="next.js">Next.js</MultiSelectItem> <MultiSelectItem value="sveltekit">SvelteKit</MultiSelectItem> <MultiSelectItem value="astro">Astro</MultiSelectItem> <MultiSelectItem value="vue">Vue.js</MultiSelectItem> </MultiSelectGroup> </MultiSelectContent></MultiSelect>
Examples
Section titled “Examples”Customize Badges
Section titled “Customize Badges”import { MultiSelect, MultiSelectContent, MultiSelectGroup, MultiSelectItem, MultiSelectTrigger, MultiSelectValue,} from "@/components/ui/multi-select"
export function CustomizeBadgesMultiSelect() { return ( <MultiSelect> <MultiSelectTrigger className="w-full max-w-[400px]"> <MultiSelectValue placeholder="Select fruit..." clickToRemove={false} /> </MultiSelectTrigger> <MultiSelectContent> <MultiSelectGroup> <MultiSelectItem value="apple" badgeLabel="🍎"> Apple </MultiSelectItem> <MultiSelectItem value="banana" badgeLabel="🍌"> Banana </MultiSelectItem> <MultiSelectItem value="cherry" badgeLabel="🍒"> Cherry </MultiSelectItem> <MultiSelectItem value="grape" badgeLabel="🍇"> Grape </MultiSelectItem> <MultiSelectItem value="kiwi" badgeLabel="🥝"> Kiwi </MultiSelectItem> <MultiSelectItem value="orange" badgeLabel="🍊"> Orange </MultiSelectItem> </MultiSelectGroup> </MultiSelectContent> </MultiSelect> )}
Overflow Behavior
Section titled “Overflow Behavior”"use client"
import { Label } from "@/components/ui/label"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"import { MultiSelect, MultiSelectContent, MultiSelectGroup, MultiSelectItem, MultiSelectTrigger, MultiSelectValue,} from "@/components/ui/multi-select"import { useState } from "react"
type OverflowBehavior = "wrap-when-open" | "wrap" | "cutoff"
export function OverflowBehaviorMultiSelect() { const [overflowBehavior, setOverflowBehavior] = useState<OverflowBehavior>("wrap-when-open")
return ( <div className="flex w-[400px] flex-col gap-8"> <div className="flex flex-col gap-2"> <Label>Overflow Behavior</Label> <Select value={overflowBehavior} onValueChange={v => setOverflowBehavior(v as OverflowBehavior)} > <SelectTrigger className="w-[180px]"> <SelectValue /> </SelectTrigger> <SelectContent> <SelectItem value="wrap-when-open">wrap-when-open</SelectItem> <SelectItem value="wrap">wrap</SelectItem> <SelectItem value="cutoff">cutoff</SelectItem> </SelectContent> </Select> </div>
<div className="flex flex-col gap-2"> <Label>Frameworks</Label> <MultiSelect defaultValues={[ "next.js", "sveltekit", "nuxt.js", "remix", "astro", "vue", ]} > <MultiSelectTrigger className="w-full"> <MultiSelectValue overflowBehavior={overflowBehavior} overflowContent={a => (a === 1 ? `+${a} other` : `+${a} others`)} /> </MultiSelectTrigger> <MultiSelectContent> <MultiSelectGroup> <MultiSelectItem value="next.js">Next.js</MultiSelectItem> <MultiSelectItem value="sveltekit">SvelteKit</MultiSelectItem> <MultiSelectItem value="nuxt.js">Nuxt.js</MultiSelectItem> <MultiSelectItem value="remix">Remix</MultiSelectItem> <MultiSelectItem value="astro">Astro</MultiSelectItem> <MultiSelectItem value="vue">Vue.js</MultiSelectItem> <MultiSelectItem value="react">React</MultiSelectItem> </MultiSelectGroup> </MultiSelectContent> </MultiSelect> </div> </div> )}
Search Configuration
Section titled “Search Configuration”import { Label } from "@/components/ui/label"import { MultiSelect, MultiSelectContent, MultiSelectGroup, MultiSelectItem, MultiSelectTrigger, MultiSelectValue,} from "@/components/ui/multi-select"
export function SearchConfigurationMultiSelect() { return ( <div className="flex w-[400px] flex-col gap-8"> <div className="flex flex-col gap-2"> <Label>Disabled Search</Label> <MultiSelect> <MultiSelectTrigger className="w-full"> <MultiSelectValue /> </MultiSelectTrigger> <MultiSelectContent search={false}> <MultiSelectGroup> <MultiSelectItem value="next.js">Next.js</MultiSelectItem> <MultiSelectItem value="sveltekit">SvelteKit</MultiSelectItem> <MultiSelectItem value="nuxt.js">Nuxt.js</MultiSelectItem> <MultiSelectItem value="remix">Remix</MultiSelectItem> <MultiSelectItem value="astro">Astro</MultiSelectItem> <MultiSelectItem value="vue">Vue.js</MultiSelectItem> <MultiSelectItem value="react">React</MultiSelectItem> </MultiSelectGroup> </MultiSelectContent> </MultiSelect> </div> <div className="flex flex-col gap-2"> <Label>Customized Search</Label> <MultiSelect> <MultiSelectTrigger className="w-full"> <MultiSelectValue /> </MultiSelectTrigger> <MultiSelectContent search={{ emptyMessage: "No frameworks found", placeholder: "Search frameworks...", }} > <MultiSelectGroup> <MultiSelectItem value="next.js">Next.js</MultiSelectItem> <MultiSelectItem value="sveltekit">SvelteKit</MultiSelectItem> <MultiSelectItem value="nuxt.js">Nuxt.js</MultiSelectItem> <MultiSelectItem value="remix">Remix</MultiSelectItem> <MultiSelectItem value="astro">Astro</MultiSelectItem> <MultiSelectItem value="vue">Vue.js</MultiSelectItem> <MultiSelectItem value="react">React</MultiSelectItem> </MultiSelectGroup> </MultiSelectContent> </MultiSelect> </div> </div> )}
"use client"
import { zodResolver } from "@hookform/resolvers/zod"import { useForm } from "react-hook-form"import { toast } from "sonner"import { z } from "zod"import { Button } from "@/components/ui/button"import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage,} from "@/components/ui/form"import { MultiSelect, MultiSelectContent, MultiSelectGroup, MultiSelectItem, MultiSelectTrigger, MultiSelectValue,} from "@/components/ui/multi-select"
const formSchema = z.object({ favoriteFrameworks: z.array(z.string()).min(1, "Required"),})
export function InputForm() { const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { favoriteFrameworks: [], }, })
function onSubmit(data: z.infer<typeof formSchema>) { toast("You submitted the following values", { description: ( <pre className="mt-2 w-[320px] rounded-md bg-neutral-950 p-4"> <code className="text-white">{JSON.stringify(data, null, 2)}</code> </pre> ), }) }
return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="w-2/3 space-y-6"> <FormField control={form.control} name="favoriteFrameworks" render={({ field }) => ( <FormItem> <FormLabel>Favorite Frameworks</FormLabel> <MultiSelect onValuesChange={field.onChange} values={field.value}> <FormControl> <MultiSelectTrigger className="w-full"> <MultiSelectValue placeholder="Select frameworks..." /> </MultiSelectTrigger> </FormControl> <MultiSelectContent> <MultiSelectGroup> <MultiSelectItem value="next.js">Next.js</MultiSelectItem> <MultiSelectItem value="sveltekit"> SvelteKit </MultiSelectItem> <MultiSelectItem value="nuxt.js">Nuxt.js</MultiSelectItem> <MultiSelectItem value="remix">Remix</MultiSelectItem> <MultiSelectItem value="astro">Astro</MultiSelectItem> <MultiSelectItem value="vue">Vue</MultiSelectItem> </MultiSelectGroup> </MultiSelectContent> </MultiSelect> <FormMessage /> </FormItem> )} /> <Button type="submit">Submit</Button> </form> </Form> )}