Search and select several options in a Radix popover with native checkboxes and a selection cap.
Choose up to three teams.
import { MultiSelect } from "@/components/ballmac/multi-select"
const options = [
{ value: "design", label: "Design" },
{ value: "engineering", label: "Engineering" },
{ value: "product", label: "Product" },
{ value: "support", label: "Support" },
]
export default function MultiSelectDemo() {
return (
<div className="w-full max-w-sm rounded-xl border bg-card p-5 shadow-sm">
<div className="mb-3 text-sm font-semibold">Share weekly summary</div>
<label className="mb-1.5 block text-xs text-muted-foreground">
Teams with access
</label>
<MultiSelect
options={options}
defaultValue={["design", "product"]}
label="Teams with access"
maxSelected={3}
/>
<p className="mt-3 text-xs text-muted-foreground">
Choose up to three teams.
</p>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/multi-selectInstall the dependencies.
$ pnpm add radix-ui lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/multi-select.tsx// Ballmac UI: Multi Select. https://ui.ballmac.com/components/multi-select "use client" import * as React from "react" import { Check, ChevronDown, Search } from "lucide-react" import { Popover as PopoverPrimitive } from "radix-ui" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type MultiSelectOption = { value: string; label: string; disabled?: boolean } type MultiSelectProps = Omit<React.ComponentProps<"div">, "onChange"> & { /** Options available for selection. */ options: MultiSelectOption[] /** Controlled selected values. */ value?: string[] /** Initial values when uncontrolled. */ defaultValue?: string[] /** Called with all selected values after a change. */ onValueChange?: (value: string[]) => void /** Accessible name of the selector. */ label?: string /** Placeholder when no option is selected. */ placeholder?: string /** Maximum number of selections. */ maxSelected?: number /** Disable the selector. */ disabled?: boolean /** Name for repeated hidden form inputs. */ name?: string } function MultiSelect({ options, value, defaultValue = [], onValueChange, label, placeholder, maxSelected = Number.POSITIVE_INFINITY, disabled = false, name, className, ...props }: MultiSelectProps) { const msg = useMessages() label ??= msg("multi-select.label", "Select options") placeholder ??= msg("multi-select.placeholder", "Choose options") const [internal, setInternal] = React.useState(defaultValue) const [query, setQuery] = React.useState("") const [open, setOpen] = React.useState(false) const selected = value ?? internal const id = React.useId() const filtered = options.filter((option) => option.label .toLocaleLowerCase("en-US") .includes(query.toLocaleLowerCase("en-US")), ) function commit(next: string[]) { if (value === undefined) setInternal(next) onValueChange?.(next) } function toggle(item: string) { if (selected.includes(item)) commit(selected.filter((value) => value !== item)) else if (selected.length < maxSelected) commit([...selected, item]) } return ( <div data-slot="multi-select" className={cn("w-full min-w-0", className)} {...props} > <PopoverPrimitive.Root open={open} onOpenChange={setOpen}> <PopoverPrimitive.Trigger data-slot="multi-select-trigger" disabled={disabled} aria-label={label} className="flex min-h-9 w-full min-w-0 items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-start text-sm shadow-xs outline-none transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50" > {selected.length ? ( <span className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden"> {selected.slice(0, 2).map((item) => ( <span key={item} className="max-w-24 shrink-0 truncate rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground" > {options.find((option) => option.value === item)?.label ?? item} </span> ))} {selected.length > 2 && ( <span className="shrink-0 text-xs text-muted-foreground"> +{selected.length - 2} </span> )} </span> ) : ( <span className="min-w-0 truncate text-muted-foreground"> {placeholder} </span> )} <ChevronDown aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" /> </PopoverPrimitive.Trigger> <PopoverPrimitive.Portal> <PopoverPrimitive.Content data-slot="multi-select-content" align="start" sideOffset={5} className="z-50 w-[min(var(--radix-popover-trigger-width),calc(100vw-2rem))] rounded-xl border bg-popover p-2 text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 motion-reduce:animate-none" > <div className="flex items-center gap-2 rounded-md border border-input px-2 focus-within:ring-[3px] focus-within:ring-ring/50"> <Search aria-hidden="true" className="size-4 text-muted-foreground" /> <input data-slot="multi-select-search" aria-label={msg("multi-select.searchOptions", "Search options")} value={query} onChange={(event) => setQuery(event.target.value)} className="h-9 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" placeholder={msg("multi-select.searchOptions2", "Search options…")} /> </div> <div role="group" aria-label={label} tabIndex={0} className="mt-1 max-h-56 overflow-y-auto outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > {filtered.length ? ( filtered.map((option) => { const checked = selected.includes(option.value) return ( <label key={option.value} className={cn( "flex min-h-9 cursor-pointer items-center gap-2 rounded-md px-2 text-sm hover:bg-accent", (option.disabled || (!checked && selected.length >= maxSelected)) && "cursor-not-allowed opacity-50", )} > <input type="checkbox" checked={checked} disabled={ option.disabled || (!checked && selected.length >= maxSelected) } onChange={() => toggle(option.value)} className="sr-only peer" /> <span aria-hidden="true" className="flex size-4 items-center justify-center rounded border border-input peer-checked:border-primary peer-checked:bg-primary peer-checked:text-primary-foreground peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50" > <Check className={cn("size-3", !checked && "hidden")} /> </span> <span className="min-w-0 flex-1 truncate"> {option.label} </span> </label> ) }) ) : ( <p className="px-2 py-4 text-center text-sm text-muted-foreground"> {msg("multi-select.noMatchingOptions", "No matching options")} </p> )} </div> <p id={id} className="mt-1 px-2 text-xs text-muted-foreground"> {Number.isFinite(maxSelected) ? msg("multi-select.selectedOf", "{count} selected of {max}", { count: selected.length, max: maxSelected }) : msg("multi-select.selected", "{count} selected", { count: selected.length })} </p> </PopoverPrimitive.Content> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> {name && selected.map((item) => ( <input key={item} type="hidden" name={name} value={item} /> ))} </div> ) } export { MultiSelect, type MultiSelectProps, type MultiSelectOption }Update the import paths to match your project setup.
Usage
import { MultiSelect } from "@/components/ballmac/multi-select"The full example is in the Code tab above.
Examples
States and variants
Filter by status
import { MultiSelect } from "@/components/ballmac/multi-select"
const options = [
{ value: "ready", label: "Ready" },
{ value: "review", label: "Needs review" },
{ value: "paused", label: "Paused", disabled: true },
{ value: "done", label: "Completed" },
]
export default function MultiSelectStates() {
return (
<div className="w-full max-w-sm">
<p className="mb-2 text-sm font-medium">Filter by status</p>
<MultiSelect
options={options}
label="Filter by status"
placeholder="All statuses"
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
options*Options available for selection. | MultiSelectOption[] | — |
valueControlled selected values. | string[] | — |
defaultValueInitial values when uncontrolled. | string[] | [] |
onValueChangeCalled with all selected values after a change. | (value: string[]) => void | — |
labelAccessible name of the selector. | string | — |
placeholderPlaceholder when no option is selected. | string | — |
maxSelectedMaximum number of selections. | number | Number.POSITIVE_INFINITY |
disabledDisable the selector. | boolean | false |
nameName for repeated hidden form inputs. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space | Opens the option popover |
| Tab / Space | Moves to and toggles native checkboxes |
| Escape | Closes the popover |
Use with AI
Search and select several options in a Radix popover with native checkboxes and a selection cap. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Multi Select (@ballmac/multi-select) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Choose multiple teammates or categories
- Filter a list by several states
Not for
- Use select when only one option is allowed
Registry JSON: https://ui.ballmac.com/r/multi-select.json
Credits
Free to use in personal and commercial projects.
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Select
A Radix select with a sized trigger, popper-positioned menu, scroll buttons, groups, labels, separators and a check indicator on the chosen item.
Calendar
A date grid for one day, several days or a range, with month and year selects, week numbers, disabled rules and range-end styling, on React DayPicker.
Color Picker
A native color chooser paired with an editable hex field and a live swatch.
Combobox
A searchable single-choice select with groups, descriptions, keywords, clearable value, invalid state and hidden-input form submission.