Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new
DocumentationMultiSelect

MultiSelect

Select several options from a list, with optional creation of values the list does not contain.

Interactive preview

Rendered from the shared Frontfriend stories with React Base, the same preview Studio shows. Styling comes from your Design System; the code below is native to Reka UI.

Installation

Complete project setup first. Your configuration must select vue/reka and include your Design System ID.

pnpm exec frontfriend download multi-select -f vue --primitive reka

Review dependencies reported by the downloader and keep your source changes in version control.

Usage

<script setup lang="ts">
import { MultiSelect } from "@/components/ui/multi-select";
import { reactive } from "vue";

const args = reactive(
  {
    "placeholder": "Select...",
    "maxCount": 3,
    "selectAll": false,
    "clearable": true,
    "createNew": false,
    "batchCreate": false,
    "searchPlaceholder": "Search...",
    "closeOnSelect": false,
    "options": [
        { label: "Italy", value: "italy" },
        { label: "United Kingdom", value: "uk" },
    ],
    "value": ["italy", "uk"],
  } satisfies Partial<
    InstanceType<typeof MultiSelect> extends { $props: infer Props }
      ? Props
      : {}
  > &
    Record<string, unknown>,
);
</script>

<template>
  <MultiSelect
    v-model="args.value"
    :options="args.options"
    :placeholder="args.placeholder"
    :max-count="args.maxCount"
    :select-all="args.selectAll"
    :clearable="args.clearable"
    :create-new="args.createNew"
    :batch-create="args.batchCreate"
    :search-placeholder="args.searchPlaceholder"
    :close-on-select="args.closeOnSelect"
  />
</template>

Behavior

  • createNew and batchCreate are opt-in; batchCreate is inactive when createNew is false.
  • Batch creation uses comma, semicolon, tab, and newline separators. Paste with a separator is processed immediately; typed batches are processed on Enter or Create new.
  • normalizeNewValue applies only to new candidates before empty checks, validation, deduplication, and insertion.
  • validateNewValue is synchronous and runs per normalized candidate. onCreateNew runs only after a new option is accepted.
  • Valid unique candidates are retained when other batch candidates are invalid or duplicated; value bindings update once per batch.
  • Existing unselected options are selected rather than recreated; existing selected values and repeated normalized candidates count as duplicates.
  • options remains authoritative. Created selected values render with label ?? value and do not warn or become persistent searchable options until the consumer adds them to options.
  • Search is trimmed, case-insensitive, diacritic-insensitive substring matching over label, value, and keywords, with one target-native filtering pass and no public custom filter callback.
  • Filtered Select all adds missing visible options while preserving hidden selections; when every visible option is selected it removes only visible options.
  • Batch validation errors remain visible after partial success. Duplicate-only batches show duplicateErrorMessage; mixed successful batches report duplicates through announcements without a visual duplicate error.
  • The list exposes listbox semantics and rows expose option plus aria-selected. Internal checkboxes and icons are decorative; ariaLabel falls back to label.
  • Announcements have English defaults and accept partial semantic formatter overrides without hardcoded selected/not-selected text in option labels.
  • closeOnSelect closes only after selecting a previously unselected option; deselection keeps the popover open.

Examples

Standard selection

<script setup lang="ts">
import { ref } from "vue";
import { MultiSelect } from "@/components/ui/multi-select";

const countryList = [
  { value: "italy", label: "Italy", keywords: ["roma", "it"] },
  { value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
  { value: "france", label: "France", keywords: ["paris", "fr"] },
  { value: "germany", label: "Germany", keywords: ["berlin", "de"] },
  { value: "usa", label: "United States", keywords: ["america", "us"] },
];

const TRIGGER_WIDTH = "min-w-[260px] w-auto";

const args = {
  "placeholder": "Select...",
  "maxCount": 3,
  "selectAll": false,
  "clearable": true,
  "createNew": false,
  "batchCreate": false,
  "searchPlaceholder": "Search...",
  "closeOnSelect": false,
  "options": countryList,
  "class": TRIGGER_WIDTH,
  "modelValue": undefined,
} satisfies Partial<
  InstanceType<typeof MultiSelect> extends { $props: infer Props } ? Props : {}
> &
  Record<string, unknown>;
const selected = ref(args.modelValue ?? ["italy", "uk"]);
</script>

<template>
  <MultiSelect v-bind="args" v-model="selected" />
</template>

Controlled and uncontrolled values

<script setup lang="ts">
import { ref } from "vue";
import { MultiSelect } from "@/components/ui/multi-select";

const countryList = [
  { value: "italy", label: "Italy", keywords: ["roma", "it"] },
  { value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
  { value: "france", label: "France", keywords: ["paris", "fr"] },
  { value: "germany", label: "Germany", keywords: ["berlin", "de"] },
  { value: "usa", label: "United States", keywords: ["america", "us"] },
];

const TRIGGER_WIDTH = "min-w-[260px] w-auto";

const args = {
  "placeholder": "Select...",
  "maxCount": 3,
  "selectAll": false,
  "clearable": true,
  "createNew": false,
  "batchCreate": false,
  "searchPlaceholder": "Search...",
  "closeOnSelect": false,
  "options": countryList,
  "class": TRIGGER_WIDTH,
} satisfies Partial<
  InstanceType<typeof MultiSelect> extends { $props: infer Props } ? Props : {}
> &
  Record<string, unknown>;
const controlled = ref(["italy"]);
</script>

<template>
  <div class="flex flex-col gap-4">
    <div class="flex items-center gap-2">
      <MultiSelect v-bind="args" v-model="controlled" />
      <button class="text-sm underline" @click="controlled = []">Reset</button>
    </div>
    <MultiSelect v-bind="args" :default-value="['france']" />
  </div>
</template>

Single create

<script setup lang="ts">
import { ref } from "vue";
import { MultiSelect } from "@/components/ui/multi-select";

const countryList = [
  { value: "italy", label: "Italy", keywords: ["roma", "it"] },
  { value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
  { value: "france", label: "France", keywords: ["paris", "fr"] },
  { value: "germany", label: "Germany", keywords: ["berlin", "de"] },
  { value: "usa", label: "United States", keywords: ["america", "us"] },
];

const TRIGGER_WIDTH = "min-w-[260px] w-auto";

const args = {
  "placeholder": "Select...",
  "maxCount": 3,
  "selectAll": false,
  "clearable": true,
  "createNew": true,
  "batchCreate": false,
  "searchPlaceholder": "Search...",
  "closeOnSelect": false,
  "options": countryList,
  "class": TRIGGER_WIDTH,
} satisfies Partial<
  InstanceType<typeof MultiSelect> extends { $props: infer Props } ? Props : {}
> &
  Record<string, unknown>;
const selected = ref<string[]>([]);
const created = ref<string[]>([]);
</script>

<template>
  <div class="flex flex-col gap-2">
    <MultiSelect
      v-bind="args"
      v-model="selected"
      @create-new="(option) => created.push(option.value)"
    />
    <p class="text-sm">Created: {{ created.join(", ") || "—" }}</p>
  </div>
</template>

Normalized partial batch create

<script setup lang="ts">
import { ref } from "vue";
import { MultiSelect } from "@/components/ui/multi-select";

const TRIGGER_WIDTH = "min-w-[260px] w-auto";

const args = {
  "placeholder": "Add recipients...",
  "maxCount": 3,
  "selectAll": false,
  "clearable": true,
  "createNew": true,
  "batchCreate": true,
  "searchPlaceholder": "Search...",
  "closeOnSelect": false,
  "options": [],
  "class": TRIGGER_WIDTH,
} satisfies Partial<
  InstanceType<typeof MultiSelect> extends { $props: infer Props } ? Props : {}
> &
  Record<string, unknown>;
const selected = ref<string[]>([]);
const normalize = (value: string) => value.trim().toLowerCase();
const validate = (value: string) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);
</script>

<template>
  <MultiSelect
    v-bind="args"
    v-model="selected"
    :normalize-new-value="normalize"
    :validate-new-value="validate"
    validation-error-message="Enter a valid email address"
    duplicate-error-message="Already added"
  />
</template>

Label, value and keyword search

<script setup lang="ts">
import { ref } from "vue";
import { MultiSelect } from "@/components/ui/multi-select";

const countryList = [
  { value: "italy", label: "Italy", keywords: ["roma", "it"] },
  { value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
  { value: "france", label: "France", keywords: ["paris", "fr"] },
  { value: "germany", label: "Germany", keywords: ["berlin", "de"] },
  { value: "usa", label: "United States", keywords: ["america", "us"] },
];

const TRIGGER_WIDTH = "min-w-[260px] w-auto";

const args = {
  "placeholder": "Select...",
  "maxCount": 3,
  "selectAll": false,
  "clearable": true,
  "createNew": false,
  "batchCreate": false,
  "searchPlaceholder": "Search...",
  "closeOnSelect": false,
  "options": countryList,
  "class": TRIGGER_WIDTH,
} satisfies Partial<
  InstanceType<typeof MultiSelect> extends { $props: infer Props } ? Props : {}
> &
  Record<string, unknown>;
const selected = ref<string[]>([]);
</script>

<template>
  <MultiSelect v-bind="args" v-model="selected" />
</template>

Filtered Select all merge and removal

<script setup lang="ts">
import { ref } from "vue";
import { MultiSelect } from "@/components/ui/multi-select";

const countryList = [
  { value: "italy", label: "Italy", keywords: ["roma", "it"] },
  { value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
  { value: "france", label: "France", keywords: ["paris", "fr"] },
  { value: "germany", label: "Germany", keywords: ["berlin", "de"] },
  { value: "usa", label: "United States", keywords: ["america", "us"] },
];

const TRIGGER_WIDTH = "min-w-[260px] w-auto";

const args = {
  "placeholder": "Select...",
  "maxCount": 3,
  "selectAll": true,
  "clearable": true,
  "createNew": false,
  "batchCreate": false,
  "searchPlaceholder": "Search...",
  "closeOnSelect": false,
  "options": countryList,
  "class": TRIGGER_WIDTH,
} satisfies Partial<
  InstanceType<typeof MultiSelect> extends { $props: infer Props } ? Props : {}
> &
  Record<string, unknown>;
const selected = ref(["italy"]);
</script>

<template>
  <MultiSelect v-bind="args" v-model="selected" />
</template>

Accessible announcements and selected state

<script setup lang="ts">
import { ref } from "vue";
import { MultiSelect } from "@/components/ui/multi-select";

const countryList = [
  { value: "italy", label: "Italy", keywords: ["roma", "it"] },
  { value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
  { value: "france", label: "France", keywords: ["paris", "fr"] },
  { value: "germany", label: "Germany", keywords: ["berlin", "de"] },
  { value: "usa", label: "United States", keywords: ["america", "us"] },
];

const TRIGGER_WIDTH = "min-w-[260px] w-auto";

const args = {
  "placeholder": "Select...",
  "maxCount": 3,
  "selectAll": true,
  "clearable": true,
  "createNew": false,
  "batchCreate": false,
  "searchPlaceholder": "Search...",
  "closeOnSelect": false,
  "options": countryList,
  "class": TRIGGER_WIDTH,
} satisfies Partial<
  InstanceType<typeof MultiSelect> extends { $props: infer Props } ? Props : {}
> &
  Record<string, unknown>;
const selected = ref<string[]>([]);
const announcements = {
  selected: (option: { label: string }) => `${option.label} selezionato`,
};
</script>

<template>
  <MultiSelect
    v-bind="args"
    v-model="selected"
    :announcements="announcements"
    clear-aria-label="Rimuovi tutte le selezioni"
  />
</template>

contentClassName customisation

<script setup lang="ts">
import { ref } from "vue";
import { MultiSelect } from "@/components/ui/multi-select";

const countryList = [
  { value: "italy", label: "Italy", keywords: ["roma", "it"] },
  { value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
  { value: "france", label: "France", keywords: ["paris", "fr"] },
  { value: "germany", label: "Germany", keywords: ["berlin", "de"] },
  { value: "usa", label: "United States", keywords: ["america", "us"] },
];

const TRIGGER_WIDTH = "min-w-[260px] w-auto";

const args = {
  "placeholder": "Select...",
  "maxCount": 3,
  "selectAll": false,
  "clearable": true,
  "createNew": false,
  "batchCreate": false,
  "searchPlaceholder": "Search...",
  "closeOnSelect": false,
  "options": countryList,
  "class": TRIGGER_WIDTH,
} satisfies Partial<
  InstanceType<typeof MultiSelect> extends { $props: infer Props } ? Props : {}
> &
  Record<string, unknown>;
const selected = ref<string[]>([]);
</script>

<template>
  <MultiSelect
    v-bind="args"
    v-model="selected"
    content-class="max-h-64 shadow-lg"
  />
</template>

Custom search and close on select

<script setup lang="ts">
import { ref } from "vue";
import { MultiSelect } from "@/components/ui/multi-select";

const countryList = [
  { value: "italy", label: "Italy", keywords: ["roma", "it"] },
  { value: "uk", label: "United Kingdom", keywords: ["britain", "gb"] },
  { value: "france", label: "France", keywords: ["paris", "fr"] },
  { value: "germany", label: "Germany", keywords: ["berlin", "de"] },
  { value: "usa", label: "United States", keywords: ["america", "us"] },
];

const TRIGGER_WIDTH = "min-w-[260px] w-auto";

const args = {
  "placeholder": "Select...",
  "maxCount": 3,
  "selectAll": false,
  "clearable": true,
  "createNew": false,
  "batchCreate": false,
  "searchPlaceholder": "Find a country",
  "closeOnSelect": true,
  "options": countryList,
  "class": TRIGGER_WIDTH,
} satisfies Partial<
  InstanceType<typeof MultiSelect> extends { $props: infer Props } ? Props : {}
> &
  Record<string, unknown>;
const selected = ref<string[]>([]);
</script>

<template>
  <MultiSelect v-bind="args" v-model="selected" />
</template>

API reference

Frontfriend-owned props. Native HTML and inherited primitive props remain documented by Reka UI.

Prop Type Default Description
animation Number —
announcements Object as () => Partial<MultiSelectAnnouncements> —
batchCreate Boolean — Split one create into several values on comma, semicolon, tab or newline
class String —
clearable Boolean —
clearAriaLabel String —
closeOnSelect Boolean —
contentClass String —
createNew Boolean — Allow values absent from `options` to be created from the search box
createNewText String —
defaultValue Array as () => string[] —
duplicateErrorMessage String —
emptySearchText String —
fullWidth Boolean —
maxCount Number —
modalPopover Boolean —
modelValue Array as () => string[] —
moreBadgeSubtle Boolean —
moreBadgeText String —
moreBadgeVariant String as PropType<BadgeVariant> —
normalizeNewValue Function as unknown as () => (value: string) => string —
options Array as () => MultiSelectOption[] —
placeholder String —
searchPlaceholder String —
selectAll Boolean —
selectAllText String —
validateNewValue Function as unknown as () => (value: string) => boolean —
validationErrorMessage String —

Configuration determines the actual classes and available styling hooks. See Theming & component configuration before introducing a repeated local override.