shadimage
FormCn
componentmulti-selectsource: ok · refreshed

Multi-select

Multi-select dropdown

npx shadcn add "https://formcn.dev/r/multi-select.json"
free

Interaction

dropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

popovercommandbadgebutton

Files1 · source cached 10/8/2026, 5:33:41 AM

src/components/ui/multi-select.tsx
'use client'

import { CheckIcon, ChevronsUpDownIcon, XIcon } from 'lucide-react'
import {
	type ComponentPropsWithoutRef,
	createContext,
	type ReactNode,
	useCallback,
	useContext,
	useEffect,
	useRef,
	useState,
} from 'react'
import { Badge } from '@/components/ui/badge'
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 { cn } from '@/lib/utils'

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 [internalValues, setInternalValues] = useState(
		new Set<string>(values ?? defaultValues),
	)
	const selectedValues = values ? new Set(values) : internalValues
	const [items, setItems] = useState<Map<string, ReactNode>>(new Map())

	function toggleValue(value: string) {
		const getNewSet = (prev: Set<string>) => {
			const newSet = new Set(prev)
			if (newSet.has(value)) {
				newSet.delete(value)
			} else {
				newSet.add(value)
			}
			return newSet
		}
		setInternalValues(getNewSet)
		onValuesChange?.([...getNewSet(selectedValues)])
	}

	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,
				toggleValue,
				items,
				onItemAdded,
			}}
		>
			<Popover open={open} onOpenChange={setOpen} modal={true}>
				{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',
	...props
}: {
	placeholder?: string
	clickToRemove?: boolean
	overflowBehavior?: 'wrap' | 'wrap-when-open' | 'cutoff'
} & 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 shouldWrap =
		overflowBehavior === 'wrap' ||
		(overflowBehavior === 'wrap-when-open' && open)

	const checkOverflow = useCallback(() => {
		if (valueRef.current == null) return

		const containerElement = valueRef.current
		const overflowElement = overflowRef.current
		const items = containerElement.querySelectorAll<HTMLElement>(
			'[data-selected-item]',
		)

		if (overflowElement != null) overflowElement.style.display = 'none'
		items.forEach((child) => child.style.removeProperty('display'))
		let amount = 0
		for (let i = items.length - 1; i >= 0; i--) {
			const child = items[i]!
			if (containerElement.scrollWidth <= containerElement.clientWidth) {
				break
			}
			amount = items.length - i
			child.style.display = 'none'
			overflowElement?.style.removeProperty('display')
		}
		setOverflowAmount(amount)
	}, [])

	const handleResize = useCallback(
		(node: HTMLDivElement) => {
			valueRef.current = node

			const observer = new ResizeObserver(checkOverflow)
			observer.observe(node)

			return () => {
				observer.disconnect()
				valueRef.current = null
			}
		},
		[checkOverflow],
	)

	if (selectedValues.size === 0 && placeholder) {
		return (
			<span className="min-w-0 overflow-hidden font-normal text-muted-foreground">
				{placeholder}
			</span>
		)
	}

	return (
		// @ts-expect-error caused when upgrading to Nextjs 16
		<div
			{...props}
			ref={handleResize}
			className={cn(
				'flex w-fit gap-1.5 overflow-hidden',
				shouldWrap && 'h-full flex-wrap',
				className,
			)}
		>
			{[...selectedValues]
				.filter((value) => items.has(value))
				.map((value) => (
					<Badge
						variant="outline"
						data-selected-item
						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}
			>
				+{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 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}
			onSelect={() => {
				toggleValue(value)
				onSelect?.(value)
			}}
		>
			<CheckIcon
				className={cn(
					'mr-2 ml-1.5 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
}

More from FormCn

Browse all components in this library →