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.

Built-in labels use useMultiSelectI18n() under a tree with I18nProvider (registry item fui-i18n).

When To Use

  • Pick many values with chips, async search, and optional creatable rows.
  • Use Combo Box instead for single-value select-or-create (input-as-field).
  • Use Select instead for a single constrained choice with a button trigger.

Interactions

EventBehavior
Focus input (menuTrigger='input', default)Does not open by focus alone.
Focus input (menuTrigger='focus')Opens immediately when input receives focus.
Click field wrapperOpens popover and focuses input (all menuTrigger modes).
Type query (menuTrigger !== 'manual')Opens popover while typing.
Type query (menuTrigger='manual')Keeps popover closed until a click opens it.
Select option after typingClears query and closes when closeWhenSearchClearedAfterSelect is true (default).
Select option while browsing (empty query)Stays open so users can keep selecting.
Blur outside / outside click / EscapeCloses popover.

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

useMultiSelect is the view-model: selected chips, search input props, and open state. The demo uses native <input>, <ul> / <li>, and <button> — not MultiSelectAnchor. MultiSelectDropdown remains the library list surface; Command is required for cmdk filtering.

value:

"use client";

import { useRef } from "react";

import { Command } from "@/components/ui/command";
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";

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,
  );

  const { ref: inputRef, ...inputProps } = s.inputProps;

  return (
    <div>
      <Popover modal={false} open={s.open}>
        <Command
          {...s.command.rest}
          filter={s.command.filter}
          onKeyDown={s.command.onKeyDown}
          shouldFilter={s.command.shouldFilter}
        >
          <div
            ref={s.wrapperRef}
            onMouseDown={(event) => {
              if (!(event.target instanceof HTMLInputElement)) {
                event.preventDefault();
              }
            }}
          >
            <label htmlFor="headless-multi-select">Tags</label>
            <PopoverAnchor asChild>
              <div ref={s.anchorRef}>
                {s.selected.length > 0 ? (
                  <ul>
                    {s.selected.map((option) => (
                      <li key={option.value}>
                        {option.label}
                        <button
                          type="button"
                          onClick={() => s.onUnselect(option)}
                          aria-label={s.t("multiSelect.removeItem", {
                            label: option.label,
                          })}
                        >
                          Remove
                        </button>
                      </li>
                    ))}
                  </ul>
                ) : null}
                <input id="headless-multi-select" ref={inputRef} {...inputProps} />
                {!s.clearHidden ? (
                  <button type="button" onClick={s.onClearAll}>
                    {s.t("multiSelect.clearAll")}
                  </button>
                ) : null}
              </div>
            </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>
      <p>
        value:{" "}
        {s.selected.length > 0 ? s.selected.map((o) => o.label).join(", ") : "—"}
      </p>
    </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), menuTrigger ('input' | 'focus' | 'manual'), closeWhenSearchClearedAfterSelect, 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