Multi-Select
Searchable multi-select with chips, async options, i18n, and a headless hook for custom layouts.
Pick many values from a searchable list; selected items appear as removable chips. Use MultiSelect when you want a label and helper text, or MultiSelectControl for the field only. For full control over layout, call useMultiSelect and compose MultiSelectAnchor, MultiSelectDropdown, and MultiSelectCommandEmpty with Popover and Command (same building blocks as the built-in control). Installing the component adds command, label, badge, and popover from the shadcn registry, plus cmdk and lucide-react.
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/multi-select.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/multi-select.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/multi-select.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/multi-select.jsonOr with a namespace: npx shadcn@latest add @f-ui/multi-select.
The CLI installs cmdk and lucide-react, and pulls command, label, badge, and popover from the default shadcn registry.
Usage
import { MultiSelect } from '@/components/f-ui/multi-select/multi-select';
import type { Option } from '@/components/f-ui/multi-select/multi-select-types';
const options: Option[] = [
{ label: 'Alpha', value: 'alpha' },
{ label: 'Beta', value: 'beta' },
];
<MultiSelect defaultOptions={options} placeholder="Choose…" />Examples
Frameworks — placeholder, clear, optional label, and helper line. All selected chips stay visible (no cap).
"use client";
import { MultiSelect } from "@/components/f-ui/multi-select/multi-select";
import type { Option } from "@/components/f-ui/multi-select/multi-select-types";
import { m } from "@/paraglide/messages";
const frameworks: Option[] = [
{ label: "Next.js", value: "next.js" },
{ label: "SvelteKit", value: "sveltekit" },
{ label: "Nuxt.js", value: "nuxt.js" },
{ label: "Remix", value: "remix" },
{ label: "Astro", value: "astro" },
{ label: "Angular", value: "angular" },
{ label: "Vue.js", value: "vue" },
{ label: "React", value: "react" },
{ label: "Ember.js", value: "ember" },
{ label: "Gatsby", value: "gatsby" },
{ label: "Eleventy", value: "eleventy" },
{ label: "SolidJS", value: "solid" },
{ label: "Preact", value: "preact" },
{ label: "Qwik", value: "qwik" },
{ label: "Alpine.js", value: "alpine" },
{ label: "Lit", value: "lit" },
];
export function MultiSelectDemo() {
return (
<div className="max-w-md *:not-first:mt-2">
<MultiSelect
defaultOptions={frameworks}
placeholder={m.docs_multi_select_placeholder()}
/>
</div>
);
}Chip overflow (maxVisibleChips) — when you pick more than maxVisibleChips, extra selections fold into +k (expand / Show less), similar to Outlook recipient fields.
"use client";
import { MultiSelect } from "@/components/f-ui/multi-select/multi-select";
import type { Option } from "@/components/f-ui/multi-select/multi-select-types";
import { m } from "@/paraglide/messages";
const frameworks: Option[] = [
{ label: "Next.js", value: "next.js" },
{ label: "SvelteKit", value: "sveltekit" },
{ label: "Nuxt.js", value: "nuxt.js" },
{ label: "Remix", value: "remix" },
{ label: "Astro", value: "astro" },
{ label: "Angular", value: "angular" },
{ label: "Vue.js", value: "vue" },
{ label: "React", value: "react" },
{ label: "Ember.js", value: "ember" },
{ label: "Gatsby", value: "gatsby" },
{ label: "Eleventy", value: "eleventy" },
{ label: "SolidJS", value: "solid" },
{ label: "Preact", value: "preact" },
{ label: "Qwik", value: "qwik" },
{ label: "Alpine.js", value: "alpine" },
{ label: "Lit", value: "lit" },
];
export function MultiSelectOverflowDemo() {
return (
<div className="max-w-md *:not-first:mt-2">
<MultiSelect
defaultOptions={frameworks}
maxVisibleChips={3}
placeholder={m.docs_multi_select_placeholder()}
/>
</div>
);
}Async search (onSearch) — options load from a Promise<Option[]> (here a fake delay + filter). triggerSearchOnFocus runs an initial fetch when the field opens; delay debounces keystrokes; loadingIndicator replaces the default spinner while waiting.
"use client";
import { Loader2 } from "lucide-react";
import { useCallback } from "react";
import { MultiSelect } from "@/components/f-ui/multi-select/multi-select";
import type { Option } from "@/components/f-ui/multi-select/multi-select-types";
/** Static pool “loaded” asynchronously — replace with your API in real apps. */
const COUNTRIES: Option[] = [
{ label: "Argentina", value: "AR" },
{ label: "Australia", value: "AU" },
{ label: "Austria", value: "AT" },
{ label: "Belgium", value: "BE" },
{ label: "Brazil", value: "BR" },
{ label: "Canada", value: "CA" },
{ label: "Chile", value: "CL" },
{ label: "China", value: "CN" },
{ label: "Colombia", value: "CO" },
{ label: "Denmark", value: "DK" },
{ label: "Egypt", value: "EG" },
{ label: "Finland", value: "FI" },
{ label: "France", value: "FR" },
{ label: "Germany", value: "DE" },
{ label: "Greece", value: "GR" },
{ label: "India", value: "IN" },
{ label: "Indonesia", value: "ID" },
{ label: "Ireland", value: "IE" },
{ label: "Italy", value: "IT" },
{ label: "Japan", value: "JP" },
{ label: "Kenya", value: "KE" },
{ label: "Mexico", value: "MX" },
{ label: "Netherlands", value: "NL" },
{ label: "New Zealand", value: "NZ" },
{ label: "Norway", value: "NO" },
{ label: "Peru", value: "PE" },
{ label: "Poland", value: "PL" },
{ label: "Portugal", value: "PT" },
{ label: "Singapore", value: "SG" },
{ label: "South Korea", value: "KR" },
{ label: "Spain", value: "ES" },
{ label: "Sweden", value: "SE" },
{ label: "Switzerland", value: "CH" },
{ label: "Thailand", value: "TH" },
{ label: "Turkey", value: "TR" },
{ label: "United Kingdom", value: "GB" },
{ label: "United States", value: "US" },
{ label: "Vietnam", value: "VN" },
];
function delay(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
export function MultiSelectAsyncDemo() {
const onSearch = useCallback(async (query: string) => {
await delay(400);
const q = query.trim().toLowerCase();
if (!q) {
return COUNTRIES.slice(0, 12);
}
return COUNTRIES.filter(
(o) =>
o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),
);
}, []);
return (
<div className="max-w-md *:not-first:mt-2">
<MultiSelect
defaultOptions={[]}
delay={300}
loadingIndicator={<Loader2 aria-hidden className="animate-spin" />}
onSearch={onSearch}
triggerSearchOnFocus
/>
</div>
);
}Headless usage
Call useMultiSelect(fieldProps, ref) and compose Popover, Command, MultiSelectAnchor, and MultiSelectDropdown the same way MultiSelectControl does. Ref is required for useImperativeHandle.
Same behavior as MultiSelectControl, built from useMultiSelect + MultiSelectAnchor + MultiSelectDropdown.
"use client";
import { useRef } from "react";
import { Command } from "@/components/ui/command";
import { MultiSelectAnchor } from "@/components/f-ui/multi-select/multi-select-parts/multi-select-anchor";
import { MultiSelectDropdown } from "@/components/f-ui/multi-select/multi-select-parts/multi-select-dropdown";
import type {
MultiSelectRef,
Option,
} from "@/components/f-ui/multi-select/multi-select-types";
import { useMultiSelect } from "@/components/f-ui/multi-select/use-multi-select";
import { Popover, PopoverAnchor } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
const SAMPLE: Option[] = [
{ label: "Alpha", value: "alpha" },
{ label: "Beta", value: "beta" },
{ label: "Gamma", value: "gamma" },
];
export function MultiSelectHeadlessDemo() {
const ref = useRef<MultiSelectRef>(null);
const s = useMultiSelect(
{
defaultOptions: SAMPLE,
onSearchSync: (q) =>
SAMPLE.filter((o) =>
o.label.toLowerCase().includes(q.toLowerCase()),
),
},
ref,
);
return (
<div className="max-w-md *:not-first:mt-2">
<p className="text-muted-foreground text-xs">
Same behavior as <code className="text-foreground">MultiSelectControl</code>, built from{" "}
<code className="text-foreground">useMultiSelect</code> +{" "}
<code className="text-foreground">MultiSelectAnchor</code> +{" "}
<code className="text-foreground">MultiSelectDropdown</code>.
</p>
<Popover modal={false} open={s.open}>
<Command
{...s.command.rest}
className={cn(
"h-auto overflow-visible bg-transparent",
s.command.className,
)}
filter={s.command.filter}
onKeyDown={s.command.onKeyDown}
shouldFilter={s.command.shouldFilter}
>
<div
onMouseDown={(e) => {
if (!(e.target instanceof HTMLInputElement)) {
e.preventDefault();
}
}}
ref={s.wrapperRef}
>
<PopoverAnchor asChild>
<MultiSelectAnchor
ref={s.anchorRef}
clearAllLabel={s.t("multiSelect.clearAll")}
getRemoveItemAriaLabel={(option) =>
s.t("multiSelect.removeItem", { label: option.label })
}
maxVisibleChips={s.maxVisibleChips}
open={s.open}
overflowChipClassName={s.overflowChipClassName}
overflowLabels={s.overflowLabels}
badgeClassName={s.badgeClassName}
chevronHidden={s.chevronHidden}
className={s.anchorClassName}
clearHidden={s.clearHidden}
disabled={s.disabled}
hidePlaceholderWhenSelected={s.hidePlaceholderWhenSelected}
inputProps={s.inputProps}
onClearAll={s.onClearAll}
onClick={s.onWrapperClick}
onUnselect={s.onUnselect}
selected={s.selected}
userInputClassName={s.userInputClassName}
/>
</PopoverAnchor>
<MultiSelectDropdown
creatable={s.creatable}
empty={s.empty}
isLoading={s.isLoading}
loadingIndicator={s.loadingIndicator}
loadingLabel={s.t("multiSelect.loading")}
onOptionSelect={s.onOptionSelect}
selectables={s.selectables}
selectFirstItem={s.selectFirstItem}
/>
</div>
</Command>
</Popover>
</div>
);
}Composition
MultiSelect (optional label + description shell)
└── MultiSelectControl
├── Popover
└── Command
├── PopoverAnchor → MultiSelectAnchor
└── MultiSelectDropdown
└── PopoverContent → CommandList
├── MultiSelectCommandEmpty (cmdk empty)
└── CommandGroup / CommandItemAPI Reference
Props
| Prop | Type | Default |
|---|---|---|
label | ReactNode | — |
description | ReactNode | — |
id | string | — (passed through via inputProps.id) |
placeholder | string | "Select options" |
classNames | Partial<Record<'root' | 'label' | 'control', string>> | — |
All MultiSelectFieldProps options apply (see multi-select-types.ts): defaultOptions, options, onChange, onSearch, creatable, maxSelected, maxVisibleChips (Outlook-style +k overflow for many chips), commandProps, inputProps, hideClearAllButton, etc.
Unless overridden, emptyIndicator defaults to centered “No results found”, and commandProps.label defaults to placeholder.
Slots
| Slot | Applied to |
|---|---|
root | Wrapper around label + control + description |
label | Label |
control | MultiSelectControl root (className on the anchor row) |
Hook
| Function | useMultiSelect(props: UseMultiSelectOptions, ref: React.Ref<MultiSelectRef>) |
| Options | UseMultiSelectOptions — same shape as MultiSelectFieldProps. |
| Return | UseMultiSelectReturn: popover state, command, refs, anchor + input bindings, list handlers, empty / creatable, selectables, etc. |
Types
multi-select-types.ts: Option, MultiSelectFieldProps, MultiSelectProps, MultiSelectRef, MultiSelectSlot. multi-select.tsx also re-exports useMultiSelect, useDebounce, and MultiSelectControl.