Inputs
Multi-select
Pick several values from a list as chips, with groups, descriptions, select all and options that load from a server as people type.
npx prodular-ui add multi-selectGrouped with select all
Options grouped by team with a second line. Select all respects disabled options and the current filter.
Maya Okafor
Priya Raman
Grouped by team. Elena is on leave until November 3 and cannot be picked. 2 of 8 selected.
"use client"
import * as React from "react"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"import { MultiSelect, type MultiSelectOption } from "@/components/ui/multi-select"
const people: MultiSelectOption[] = [ { value: "maya", label: "Maya Okafor", description: "Head of finance", group: "Finance" }, { value: "priya", label: "Priya Raman", description: "Controller", group: "Finance" }, { value: "theo", label: "Theo Lindqvist", description: "Accounts payable", group: "Finance" }, { value: "renata", label: "Renata Alves", description: "Accounts receivable", group: "Finance" }, { value: "jonas", label: "Jonas Weber", description: "VP operations", group: "Operations" }, { value: "aiko", label: "Aiko Tanaka", description: "Procurement", group: "Operations" }, { value: "samir", label: "Samir Haddad", description: "General counsel", group: "Legal" }, { value: "elena", label: "Elena Petrova", description: "Contracts", group: "Legal", disabled: true },]
export default function MultiSelectApprovers() { const [approvers, setApprovers] = React.useState(["maya", "priya"]) return ( <Field className="w-full max-w-md"> <FieldLabel>Approvers for payments over $10,000</FieldLabel> <MultiSelect options={people} value={approvers} onValueChange={setApprovers} selectAll placeholder="Add approvers" /> <FieldDescription> Grouped by team. Elena is on leave until November 3 and cannot be picked. {approvers.length} of {people.length} selected. </FieldDescription> </Field> )}Async search
loadOptions runs after a short pause in typing and cancels the previous request. Chips keep their labels as results change.
kestrel-health
willow-pharmacy
+2Results load as you type. Chips past the second collapse into a count.
"use client"
import * as React from "react"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"import { MultiSelect, type MultiSelectOption } from "@/components/ui/multi-select"
const names = [ "Northwind Logistics", "Kestrel Health", "Brightline Studio", "Meridian Freight", "Lumen Analytics", "Halden Health", "Copper Robotics", "Willow Pharmacy", "Juniper Dental", "Ostara Foods", "Tidewater Energy", "Sundial Hospitality", "Harbor Learning", "Alder Logistics", "Amberly Health", "Evergreen Partners", "Indigo Labs", "Maple Ventures", "Cobalt Ridge Capital", "Northbeam Cloud",]
const customers: MultiSelectOption[] = names.map((name, i) => ({ value: name.toLowerCase().replace(/\s+/g, "-"), label: name, description: `${4 + ((i * 7) % 23)} open invoices`,}))
// Stands in for a request to /api/customers?q=. Results arrive after 350ms.function searchCustomers(query: string, signal: AbortSignal) { return new Promise<MultiSelectOption[]>((resolve, reject) => { const t = setTimeout(() => resolve(customers.filter((c) => c.label.toLowerCase().includes(query.toLowerCase())).slice(0, 8)), 350) signal.addEventListener("abort", () => { clearTimeout(t) reject(new DOMException("Aborted", "AbortError")) }) })}
export default function MultiSelectAsync() { const [selected, setSelected] = React.useState(["kestrel-health", "willow-pharmacy", "juniper-dental", "copper-robotics"]) return ( <Field className="w-full max-w-md"> <FieldLabel>Send reminders to</FieldLabel> <MultiSelect loadOptions={searchCustomers} value={selected} onValueChange={setSelected} maxChips={2} placeholder="Search customers" /> <FieldDescription>Results load as you type. Chips past the second collapse into a count.</FieldDescription> </Field> )}Short list
A plain list with a hidden input per value for form posts.
Email
Slack
import { MultiSelect } from "@/components/ui/multi-select"
const channels = [ { value: "email", label: "Email" }, { value: "slack", label: "Slack" }, { value: "teams", label: "Microsoft Teams" }, { value: "sms", label: "SMS" }, { value: "webhook", label: "Webhook" },]
export default function MultiSelectChannels() { return ( <div className="w-full max-w-sm"> <MultiSelect options={channels} defaultValue={["email", "slack"]} aria-label="Notification channels" placeholder="Pick channels" name="channels" /> </div> )}Anatomy
<Field> <FieldLabel>Approvers</FieldLabel> <MultiSelect options={people} value={ids} onValueChange={setIds} selectAll /></Field>Guidance
Use it when
- Choosing several items from a known set: people, customers, labels, channels, regions.
- loadOptions when the list is long or lives on the server.
- selectAll when picking everything is a real case, like notifying every team.
Avoid
- Two to five options that all fit on screen. Use a Checkbox group, it is faster to scan.
- Free-form values people type, like email addresses. Use Tag input.
Rules for AI agents
Published in llms-full.txt and the registry, so coding agents follow them when they build with this component.
- Give it a visible label with Field and FieldLabel, or aria-label.
- Keep values as stable ids and labels as display names. onValueChange returns ids.
- With loadOptions, honor the AbortSignal and return at most 20 results; ask people to type more instead of returning thousands.
- Set maxChips so the field stays one or two lines tall; the rest collapse into +N.
- Mark options people cannot pick as disabled and explain why in FieldDescription, do not hide them.
API
MultiSelect
| Prop | Type | Default | Description |
|---|---|---|---|
| options | { value, label, description?, group?, icon?, disabled? }[] | A fixed list, filtered as people type. Options with a group are listed under a heading. | |
| loadOptions | (query, signal) => Promise<Option[]> | Loads options for a query instead of options. | |
| value / defaultValue / onValueChange | string[] | Selected option values. | |
| maxChips | number | 3 | Chips shown before the rest collapse into +N. |
| selectAll | boolean | false | Select all and Clear selection above the list. |
| emptyText / placeholder | string | Copy for no matches and for an empty field. | |
| name | string | Renders one hidden input per value for form posts. |