f-ui
Components

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.json
npx shadcn@latest add https://ui.isaacfei.com/r/multi-select.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/multi-select.json
bun x shadcn@latest add https://ui.isaacfei.com/r/multi-select.json

Or 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 / CommandItem

API Reference

Props

PropTypeDefault
labelReactNode
descriptionReactNode
idstring— (passed through via inputProps.id)
placeholderstring"Select options"
classNamesPartial<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

SlotApplied to
rootWrapper around label + control + description
labelLabel
controlMultiSelectControl root (className on the anchor row)

Hook

FunctionuseMultiSelect(props: UseMultiSelectOptions, ref: React.Ref<MultiSelectRef>)
OptionsUseMultiSelectOptions — same shape as MultiSelectFieldProps.
ReturnUseMultiSelectReturn: 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.

On this page