MultiSelect

Searchable multi-choice picker. Matches the Combobox / Select item-slot model and provides built-in Clear All / Select All footer controls.

Default

A plain picker — button trigger opens a popover with a search input, option list, and default footer.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { MultiSelect } from 'frappe-ui'

const state = ref<string[]>([])

const options = [
  { value: 'red-apple', label: 'Red Apple' },
  { value: 'blueberry-burst', label: 'Blueberry Burst' },
  { value: 'orange-grove', label: 'Orange Grove' },
  { value: 'banana-split', label: 'Banana Split' },
  { value: 'grapes-cluster', label: 'Grapes Cluster' },
  { value: 'kiwi-slice', label: 'Kiwi Slice' },
  { value: 'mango-fusion', label: 'Mango Fusion' },
]
</script>

<template>
  <MultiSelect
    v-model="state"
    :options="options"
    placeholder="Select fruit"
    class="w-64"
  />
</template>

Item Prefix

Use #item-prefix to render avatars, icons, or indicators next to each option label.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Avatar, MultiSelect } from 'frappe-ui'

const state = ref<string[]>([])

const img =
  'https://images.unsplash.com/photo-1502741338009-cac2772e18bc?w=100&h=100&fit=crop'

const options = [
  { value: 'red-apple', label: 'Red Apple', img },
  { value: 'blueberry-burst', label: 'Blueberry Burst', img },
  { value: 'orange-grove', label: 'Orange Grove', img },
  { value: 'banana-split', label: 'Banana Split', img },
  { value: 'grapes-cluster', label: 'Grapes Cluster', img },
  { value: 'kiwi-slice', label: 'Kiwi Slice', img },
  { value: 'mango-fusion', label: 'Mango Fusion', img },
]
</script>

<template>
  <MultiSelect
    v-model="state"
    :options="options"
    placeholder="Select fruit"
    class="w-64"
  >
    <template #item-prefix="{ item }">
      <Avatar :image="(item as any).img" size="sm" />
    </template>
  </MultiSelect>
</template>

Members

Use #prefix to render an aggregate visual across the current selection — here, a stack of avatars capped at three with a "+N" overflow badge. When #prefix is provided it owns the entire prefix area regardless of selection count, so the same template handles 0 / 1 / many.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Avatar, MultiSelect } from 'frappe-ui'

type Member = {
  label: string
  value: string
  image: string
  role: string
}

const members: Member[] = [
  {
    label: 'Alex Rivera',
    value: 'alex@example.com',
    image: 'https://i.pravatar.cc/80?u=alex@example.com',
    role: 'Engineering',
  },
  {
    label: 'Priya Shah',
    value: 'priya@example.com',
    image: 'https://i.pravatar.cc/80?u=priya@example.com',
    role: 'Design',
  },
  {
    label: 'Marcus Lee',
    value: 'marcus@example.com',
    image: 'https://i.pravatar.cc/80?u=marcus@example.com',
    role: 'Product',
  },
  {
    label: 'Sofia Hartmann',
    value: 'sofia@example.com',
    image: 'https://i.pravatar.cc/80?u=sofia@example.com',
    role: 'Engineering',
  },
  {
    label: 'Kenji Tanaka',
    value: 'kenji@example.com',
    image: 'https://i.pravatar.cc/80?u=kenji@example.com',
    role: 'Design',
  },
  {
    label: 'Nadia Okafor',
    value: 'nadia@example.com',
    image: 'https://i.pravatar.cc/80?u=nadia@example.com',
    role: 'Product',
  },
]

const value = ref<string[]>(['alex@example.com', 'priya@example.com'])

const MAX_AVATARS = 3

const visibleSelected = computed(() =>
  (
    value.value
      .map((v) => members.find((m) => m.value === v))
      .filter(Boolean) as Member[]
  ).slice(0, MAX_AVATARS),
)

const overflowCount = computed(() =>
  Math.max(0, value.value.length - MAX_AVATARS),
)
</script>

<template>
  <MultiSelect
    v-model="value"
    :options="members"
    placeholder="Assign reviewers…"
    class="w-80"
  >
    <template #prefix>
      <div v-if="visibleSelected.length" class="flex -space-x-1.5">
        <Avatar
          v-for="m in visibleSelected"
          :key="m.value"
          :image="m.image"
          :label="m.label"
          size="sm"
        />
        <span
          v-if="overflowCount > 0"
          class="z-10 grid size-5 place-items-center rounded-full bg-surface-gray-3 text-p-xs font-medium text-ink-gray-7"
        >
          +{{ overflowCount }}
        </span>
      </div>
      <span v-else class="lucide-users size-4 text-ink-gray-5" />
    </template>

    <template #summary="{ selectedOptions, summary }">
      <template v-if="selectedOptions.length">
        {{ selectedOptions.map((o) => o.label).join(', ') }}
      </template>
      <template v-else>{{ summary }}</template>
    </template>

    <template #item-prefix="{ item }">
      <Avatar :image="(item as Member).image" :label="item.label" size="sm" />
    </template>

    <template #item-label="{ item }">
      <div class="min-w-0 flex justify-between">
        <div class="truncate">{{ item.label }}</div>
        <div class="truncate text-p-sm text-ink-gray-5">
          {{ (item as Member).role }}
        </div>
      </div>
    </template>
  </MultiSelect>
</template>

Grouped Options

Options can be split into named groups. Group labels render above each group's items.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { MultiSelect } from 'frappe-ui'

const state = ref<string[]>(['platform-infra'])

const options = [
  {
    group: 'Engineering',
    options: [
      { label: 'Platform Infra', value: 'platform-infra' },
      { label: 'Mobile 2.0', value: 'mobile-2' },
      { label: 'Growth', value: 'growth' },
    ],
  },
  {
    group: 'Product',
    options: [
      { label: 'Discovery', value: 'discovery' },
      { label: 'Roadmap', value: 'roadmap' },
      { label: 'Feedback', value: 'feedback' },
    ],
  },
  {
    group: 'Design',
    options: [
      { label: 'System', value: 'system' },
      { label: 'Research', value: 'research' },
      { label: 'Brand', value: 'brand' },
    ],
  },
]
</script>

<template>
  <MultiSelect
    v-model="state"
    :options="options"
    placeholder="Select spaces"
    class="w-72"
  />
</template>

Async Options

Fetch options from a server as the user types. Listen to @update:query, debounce the request, and feed the results back into :options. The :loading prop swaps the result body for a loading state. Two things to watch for: drop stale responses with a request id so a slower earlier query can't overwrite the latest results, and merge currently-selected items into the options array so chips stay resolvable after the query narrows the list.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { Avatar, MultiSelect } from 'frappe-ui'

type Member = {
  label: string
  value: string
  image: string
  role: string
}

const ALL_MEMBERS: Member[] = [
  { label: 'Alex Rivera', value: 'alex@example.com', image: 'https://i.pravatar.cc/80?u=alex@example.com', role: 'Engineering' },
  { label: 'Alexandra Chen', value: 'alexandra@example.com', image: 'https://i.pravatar.cc/80?u=alexandra@example.com', role: 'Design' },
  { label: 'Alexei Volkov', value: 'alexei@example.com', image: 'https://i.pravatar.cc/80?u=alexei@example.com', role: 'Engineering' },
  { label: 'Priya Shah', value: 'priya@example.com', image: 'https://i.pravatar.cc/80?u=priya@example.com', role: 'Design' },
  { label: 'Priyanka Mehta', value: 'priyanka@example.com', image: 'https://i.pravatar.cc/80?u=priyanka@example.com', role: 'Product' },
  { label: 'Marcus Lee', value: 'marcus@example.com', image: 'https://i.pravatar.cc/80?u=marcus@example.com', role: 'Product' },
  { label: 'Marco Silva', value: 'marco@example.com', image: 'https://i.pravatar.cc/80?u=marco@example.com', role: 'Engineering' },
  { label: 'Maria Garcia', value: 'maria@example.com', image: 'https://i.pravatar.cc/80?u=maria@example.com', role: 'Marketing' },
  { label: 'Sofia Hartmann', value: 'sofia@example.com', image: 'https://i.pravatar.cc/80?u=sofia@example.com', role: 'Engineering' },
  { label: 'Sophie Laurent', value: 'sophie@example.com', image: 'https://i.pravatar.cc/80?u=sophie@example.com', role: 'Sales' },
  { label: 'Kenji Tanaka', value: 'kenji@example.com', image: 'https://i.pravatar.cc/80?u=kenji@example.com', role: 'Design' },
  { label: 'Kenta Mori', value: 'kenta@example.com', image: 'https://i.pravatar.cc/80?u=kenta@example.com', role: 'Engineering' },
  { label: 'Nadia Okafor', value: 'nadia@example.com', image: 'https://i.pravatar.cc/80?u=nadia@example.com', role: 'Product' },
  { label: 'Diego Alvarez', value: 'diego@example.com', image: 'https://i.pravatar.cc/80?u=diego@example.com', role: 'Engineering' },
  { label: 'Lina Petrova', value: 'lina@example.com', image: 'https://i.pravatar.cc/80?u=lina@example.com', role: 'Marketing' },
  { label: 'Liam Connor', value: 'liam@example.com', image: 'https://i.pravatar.cc/80?u=liam@example.com', role: 'Product' },
  { label: 'Hassan Iqbal', value: 'hassan@example.com', image: 'https://i.pravatar.cc/80?u=hassan@example.com', role: 'Sales' },
  { label: 'Ava Nguyen', value: 'ava@example.com', image: 'https://i.pravatar.cc/80?u=ava@example.com', role: 'Engineering' },
]

// Mocks a server endpoint: 400ms latency + substring match on label/value.
function searchMembersApi(query: string): Promise<Member[]> {
  return new Promise((resolve) => {
    setTimeout(() => {
      const q = query.trim().toLowerCase()
      const matches = q
        ? ALL_MEMBERS.filter(
            (m) =>
              m.label.toLowerCase().includes(q) ||
              m.value.toLowerCase().includes(q),
          )
        : ALL_MEMBERS
      resolve(matches.slice(0, 6))
    }, 400)
  })
}

const value = ref<string[]>([])
const results = ref<Member[]>([])
const loading = ref(false)
const knownById = ref(new Map<string, Member>())

let requestId = 0
async function fetchMembers(query: string) {
  const id = ++requestId
  loading.value = true
  const members = await searchMembersApi(query)
  // Drop stale responses so an earlier-but-slower request can't overwrite
  // the latest results.
  if (id !== requestId) return
  results.value = members
  for (const m of members) knownById.value.set(m.value, m)
  loading.value = false
}

const onQueryChange = useDebounceFn(fetchMembers, 250)

// Merge currently-selected members into the options so chips stay
// resolvable after the query narrows the result set.
const options = computed<Member[]>(() => {
  const byId = new Map<string, Member>()
  for (const m of results.value) byId.set(m.value, m)
  for (const id of value.value) {
    if (!byId.has(id)) {
      const existing = knownById.value.get(id)
      if (existing) byId.set(id, existing)
    }
  }
  return Array.from(byId.values())
})

function onOpen(isOpen: boolean) {
  if (isOpen && results.value.length === 0) fetchMembers('')
}
</script>

<template>
  <MultiSelect
    v-model="value"
    :options="options"
    :loading="loading"
    placeholder="Search members…"
    empty-text="No members found"
    class="w-80"
    @update:query="onQueryChange"
    @update:open="onOpen"
  >
    <template #item-prefix="{ item }">
      <Avatar :image="(item as Member).image" :label="item.label" size="sm" />
    </template>

    <template #item-label="{ item }">
      <div class="min-w-0 flex justify-between">
        <div class="truncate">{{ item.label }}</div>
        <div class="truncate text-p-sm text-ink-gray-5">
          {{ (item as Member).role }}
        </div>
      </div>
    </template>
  </MultiSelect>
</template>

Replace the default Clear All / Select All footer with a custom one. The slot receives clearAll, selectAll, selectedOptions, and query.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Button, MultiSelect } from 'frappe-ui'

const state = ref<string[]>([])

const options = [
  { value: 'red-apple', label: 'Red Apple' },
  { value: 'blueberry-burst', label: 'Blueberry Burst' },
  { value: 'orange-grove', label: 'Orange Grove' },
  { value: 'banana-split', label: 'Banana Split' },
  { value: 'grapes-cluster', label: 'Grapes Cluster' },
  { value: 'kiwi-slice', label: 'Kiwi Slice' },
  { value: 'mango-fusion', label: 'Mango Fusion' },
]
</script>

<template>
  <MultiSelect v-model="state" :options="options" class="w-64">
    <template #footer="{ clearAll, selectAll, selectedOptions }">
      <div
        class="flex items-center justify-between gap-2 border-t border-outline-gray-1 px-2 py-1.5"
      >
        <Button theme="red" variant="ghost" @click="clearAll">
          <template #prefix>
            <span class="lucide-trash-2 size-4" />
          </template>
          Clear ({{ selectedOptions.length }})
        </Button>

        <Button variant="ghost" @click="selectAll">
          <template #prefix>
            <span class="lucide-check-check size-4" />
          </template>
          Select All
        </Button>
      </div>
    </template>
  </MultiSelect>
</template>

Custom Trigger

Use #trigger to fully replace the default button trigger. The slot receives open, disabled, selectedOptions, displayValue, clearAll, and toggleOpen.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Button, MultiSelect } from 'frappe-ui'

const state = ref<string[]>(['alice'])

const options = [
  { label: 'Alice Rivera', value: 'alice' },
  { label: 'Bao Nguyen', value: 'bao' },
  { label: 'Chen Wei', value: 'chen' },
  { label: 'Diego Ruiz', value: 'diego' },
  { label: 'Elena Park', value: 'elena' },
]
</script>

<template>
  <MultiSelect v-model="state" :options="options" placeholder="Assign to">
    <template #trigger="{ selectedOptions, open }">
      <Button icon-left="lucide-users">
        {{
          selectedOptions.length
            ? `${selectedOptions.length} assigned`
            : 'Assign to'
        }}
        <template #suffix>
          <span
            :class="[
              'lucide-chevron-down size-4 transition-transform',
              open && 'rotate-180',
            ]"
          />
        </template>
      </Button>
    </template>
  </MultiSelect>
</template>

Tags Trigger

A chips-style trigger: each selected option renders as a removable Badge, with inline remove buttons. Authored through #trigger using selectedOptions and the parent's v-model.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Badge, MultiSelect } from 'frappe-ui'

type Tag = {
  label: string
  value: string
  theme: 'gray' | 'blue' | 'green' | 'orange' | 'red'
}

const tags = ref<string[]>(['bug', 'p0'])

const tagOptions: Tag[] = [
  { label: 'Bug', value: 'bug', theme: 'red' },
  { label: 'Feature', value: 'feature', theme: 'blue' },
  { label: 'Enhancement', value: 'enhancement', theme: 'green' },
  { label: 'P0', value: 'p0', theme: 'red' },
  { label: 'P1', value: 'p1', theme: 'orange' },
  { label: 'P2', value: 'p2', theme: 'gray' },
  { label: 'Frontend', value: 'frontend', theme: 'blue' },
  { label: 'Backend', value: 'backend', theme: 'gray' },
  { label: 'Docs', value: 'docs', theme: 'green' },
]

function removeTag(value: string) {
  tags.value = tags.value.filter((v) => v !== value)
}
</script>

<template>
  <MultiSelect v-model="tags" :options="tagOptions">
    <template #trigger="{ open, selectedOptions, toggleOpen }">
      <button
        type="button"
        :data-state="open ? 'open' : 'closed'"
        class="flex w-96 min-h-8 cursor-pointer items-center gap-1.5 rounded border border-[--surface-gray-2] px-1.5 py-1 text-left outline-none transition-colors hover:border-outline-gray-modals focus-visible:ring-2 data-[state=open]:ring-2 ring-outline-gray-3"
        @click="toggleOpen"
      >
        <div class="flex min-w-0 flex-1 flex-wrap items-center gap-1">
          <Badge
            v-for="option in selectedOptions"
            :key="option.value"
            :theme="(option as Tag).theme"
            size="md"
          >
            {{ option.label }}
            <template #suffix>
              <span
                role="button"
                tabindex="-1"
                class="-mr-0.5 inline-flex cursor-pointer items-center justify-center rounded-sm p-0.5 opacity-70 hover:opacity-100"
                @click.stop="removeTag(option.value)"
                @pointerdown.stop
              >
                <span class="lucide-x size-3" />
              </span>
            </template>
          </Badge>

          <span
            v-if="!selectedOptions.length"
            class="px-1 text-base text-ink-gray-4"
          >
            Add tags…
          </span>
        </div>

        <span
          :class="[
            'lucide-chevron-down size-4 shrink-0 text-ink-gray-4 transition-transform',
            open && 'rotate-180',
          ]"
        />
      </button>
    </template>
  </MultiSelect>
</template>

Label, Description, Error

MultiSelect supports label, description, error, and required directly — no FormControl wrapper needed. The error suppresses the description and wires aria-invalid + aria-errormessage onto the trigger.

Pick as many as you like.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Checkbox, MultiSelect } from 'frappe-ui'

const value = ref<string[]>([])
const required = ref(true)
const showError = ref(false)

const error = computed(() =>
  showError.value ? 'Pick at least one fruit.' : '',
)

const options = [
  { label: 'Apple', value: 'apple' },
  { label: 'Banana', value: 'banana' },
  { label: 'Cherry', value: 'cherry' },
  { label: 'Durian', value: 'durian' },
]
</script>

<template>
  <div class="flex gap-8 items-start">
    <MultiSelect
      v-model="value"
      :options="options"
      label="Favourite fruits"
      description="Pick as many as you like."
      :error="error"
      :required="required"
      placeholder="Pick some"
      class="w-72"
    />
    <div
      class="flex flex-col gap-2 items-start border-l border-outline-gray-2 pl-6"
    >
      <Checkbox v-model="required" label="required" />
      <Checkbox v-model="showError" label="show error" />
    </div>
  </div>
</template>

API Reference

Show types
typescript
import type { Component, VNode, VNodeChild } from 'vue'
import type { InputLabelingProps } from '../../composables/useInputLabeling'

export type MultiSelectVariant = 'subtle' | 'outline' | 'ghost'
export type MultiSelectSize = 'sm' | 'md' | 'lg' | 'xl'

export type PopoverSide = 'top' | 'right' | 'bottom' | 'left'
export type PopoverAlign = 'start' | 'center' | 'end'

export type MultiSelectSlotFn<TProps> = (props: TProps) => VNodeChild

export interface MultiSelectItemSlots<TProps> {
  /** Replaces the prefix region of the standard row shell. */
  prefix?: MultiSelectSlotFn<TProps>

  /** Replaces the label region of the standard row shell. */
  label?: MultiSelectSlotFn<TProps>

  /** Replaces the suffix region of the standard row shell. */
  suffix?: MultiSelectSlotFn<TProps>

  /** Replaces the entire row; mutually exclusive with `prefix` / `label` / `suffix`. */
  item?: MultiSelectSlotFn<TProps>
}

export interface MultiSelectOption {
  label: string
  value: string
  icon?: string | Component
  description?: string
  disabled?: boolean
  slot?: string
  /** Per-item inline slot implementations for the row shell. */
  slots?: MultiSelectItemSlots<MultiSelectItemSlotProps>
  /** @deprecated use `slot` */
  slotName?: string
  /** @deprecated use `slots` — function form maps to `slots.item`, object form to `slots` */
  render?:
    | (() => VNode | VNode[])
    | MultiSelectItemSlots<MultiSelectItemSlotProps>
  [key: string]: any
}

export interface MultiSelectGroupedOption {
  key?: string | number
  group: string
  hideLabel?: boolean
  options: MultiSelectOption[]
}

export type MultiSelectOptions = Array<
  MultiSelectOption | MultiSelectGroupedOption
>

export interface MultiSelectProps extends InputLabelingProps {
  /** Array of selected option values. */
  modelValue?: string[]

  /** Options rendered in the popover. */
  options?: MultiSelectOptions

  /** Visual style of the trigger. */
  variant?: MultiSelectVariant

  /** Size of the trigger and option rows. */
  size?: MultiSelectSize

  /** Placeholder text shown when no value is selected. */
  placeholder?: string

  /** Disables the multi-select. */
  disabled?: boolean

  /** Controls the popover visibility. */
  open?: boolean

  /** Hides the in-popover search input. */
  hideSearch?: boolean

  /** Replaces the results with a loading state. */
  loading?: boolean

  /** Fallback empty-state copy. */
  emptyText?: string

  /** Preferred popover side. */
  side?: PopoverSide

  /** Preferred popover alignment. */
  align?: PopoverAlign

  /** Gap between trigger and content. */
  offset?: number

  /** Teleport target for the popover content. */
  portalTo?: string | HTMLElement

  /**
   * Custom equality function used to resolve which options are currently
   * selected for display and rendering. When omitted, the component uses
   * strict equality on `option.value` against entries in `modelValue`.
   */
  compareFn?: (a: MultiSelectOption, b: MultiSelectOption) => boolean
}

/**
 * Shared shape for `#trigger`, `#prefix`, `#suffix`, and (with an added
 * `summary` field) `#summary`. The imperative helpers `clearAll` and
 * `toggleOpen` are exposed on every slot so consumers don't need to hoist
 * into `#trigger` just to clear the selection.
 */
export interface MultiSelectSlotProps {
  /** Whether the popover is open. */
  open: boolean

  /** Whether the multi-select is disabled. */
  disabled: boolean

  /** Current search query — empty when the user hasn't typed since opening. */
  query: string

  /** Resolved option objects for the selected values, in `modelValue` order. */
  selectedOptions: MultiSelectOption[]

  /** Comma-joined labels of the selected options, or `''` when nothing is selected. */
  displayValue: string

  /** Clears all selected values. */
  clearAll: () => void

  /** Toggles the popover open state. */
  toggleOpen: () => void
}

export type MultiSelectTriggerSlotProps = MultiSelectSlotProps
export type MultiSelectPrefixSlotProps = MultiSelectSlotProps
export type MultiSelectSuffixSlotProps = MultiSelectSlotProps

export interface MultiSelectSummarySlotProps extends MultiSelectSlotProps {
  /** Default label text the trigger would render (e.g. placeholder,
   * single selected label, or `"N selected"`). Use it as a fallback. */
  summary: string
}

export interface MultiSelectItemSlotProps {
  /** Item currently being rendered. */
  item: MultiSelectOption

  /** Current search query — empty when the user hasn't typed since opening. */
  query: string

  /** Whether the item is in `modelValue`. */
  selected: boolean
}

export interface MultiSelectGroupLabelSlotProps {
  /** Group currently being rendered. */
  group: MultiSelectGroupedOption
}

export interface MultiSelectEmptySlotProps {
  /** Current search query — empty when the user hasn't typed since opening. */
  query: string
}

export interface MultiSelectFooterSlotProps {
  /** Clears all selected values. */
  clearAll: () => void

  /** Selects every enabled option across all groups. */
  selectAll: () => void

  /** Resolved option objects for the selected values, in `modelValue` order. */
  selectedOptions: MultiSelectOption[]

  /** Current search query — empty when the user hasn't typed since opening. */
  query: string
}

export interface MultiSelectSlots {
  /** Fully custom trigger renderer. */
  trigger?: (props: MultiSelectTriggerSlotProps) => any

  /**
   * Content rendered before the trigger label. When provided, this slot
   * owns the entire prefix area regardless of selection count — useful
   * for aggregate visuals like stacked avatars. If omitted, the trigger
   * auto-renders the selected option's `#item-prefix` / `icon` when
   * exactly one is selected, and nothing otherwise.
   */
  prefix?: (props: MultiSelectPrefixSlotProps) => any

  /**
   * Overrides the trigger label region. Receives the default summary
   * text as `summary` — use it as a fallback. Useful when you want to
   * show comma-separated labels (or any other format) instead of the
   * default `"N selected"` for multi-selection states.
   */
  summary?: (props: MultiSelectSummarySlotProps) => any

  /**
   * Content rendered after the trigger label. Providing this slot
   * **replaces the default chevron** — render your own fallback when
   * your slot content is conditional. Use `@click.stop` and
   * `@pointerdown.stop` so the press doesn't toggle the popover.
   */
  suffix?: (props: MultiSelectSuffixSlotProps) => any

  /** Overrides the rendered label content. Receives `{ required }`. */
  label?: (props: { required: boolean }) => any

  /** Overrides the rendered description content. */
  description?: () => any

  /** Shared content rendered before the standard row label. */
  'item-prefix'?: (props: MultiSelectItemSlotProps) => any

  /** Shared content rendered for the standard row label area. */
  'item-label'?: (props: MultiSelectItemSlotProps) => any

  /** Shared content rendered after the standard row label area. */
  'item-suffix'?: (props: MultiSelectItemSlotProps) => any

  /** Replaces the entire row. */
  item?: (props: MultiSelectItemSlotProps) => any

  /** Custom renderer for group labels. */
  'group-label'?: (props: MultiSelectGroupLabelSlotProps) => any

  /** Fallback content rendered when there are no results. */
  empty?: (props: MultiSelectEmptySlotProps) => any

  /** Replaces the default Clear All / Select All footer. */
  footer?: (props: MultiSelectFooterSlotProps) => any

  /** @deprecated compatibility alias for `#item-label`. */
  option?: (props: { item: MultiSelectOption }) => any

  [slotName: string]: ((props: any) => any) | undefined
}

export interface MultiSelectEmits {
  /** Fired when the selection changes. */
  'update:modelValue': [value: string[]]

  /** Fired when the open state changes. */
  'update:open': [value: boolean]

  /** Fired when the user types in the search input. */
  'update:query': [value: string]
}
modelValue
= []
string[]

Array of selected option values.

options
= []
MultiSelectOptions

Options rendered in the popover.

variant
= "subtle"
MultiSelectVariant

Visual style of the trigger.

size
= "sm"
MultiSelectSize

Size of the trigger and option rows.

placeholder
= "Select option"
string

Placeholder text shown when no value is selected.

disabled
= false
boolean

Disables the multi-select.

open
= false
boolean

Controls the popover visibility.

hideSearch
= false
boolean

Hides the in-popover search input.

loading
= false
boolean

Replaces the results with a loading state.

emptyText
= "No results"
string

Fallback empty-state copy.

side
= "bottom"
PopoverSide

Preferred popover side.

align
= "start"
PopoverAlign

Preferred popover alignment.

offset
= 4
number

Gap between trigger and content.

portalTo
= "body"
string | HTMLElement

Teleport target for the popover content.

compareFn
((a: MultiSelectOption, b: MultiSelectOption) => boolean)

Custom equality function used to resolve which options are currently selected for display and rendering. When omitted, the component uses strict equality on `option.value` against entries in `modelValue`.

label
string

Label rendered above (or beside, for binary controls) the input.

description
string

Helper text rendered below the input. Hidden when `error` is set.

error
string | FrappeUIError

Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. May be either a string or an `Error` object whose `messages?: string[]` is rendered as stacked lines (with `Error.message` as the fallback).

required
boolean

Marks the field as required. Renders an asterisk next to the label and forwards `required` / `aria-required` to the underlying control.

id
string

HTML id of the underlying control. Auto-generated via `useId()` if omitted.

trigger
MultiSelectSlotProps

Fully custom trigger renderer.

prefix
MultiSelectSlotProps

Content rendered before the trigger label. When provided, this slot owns the entire prefix area regardless of selection count — useful for aggregate visuals like stacked avatars. If omitted, the trigger auto-renders the selected option's `#item-prefix` / `icon` when exactly one is selected, and nothing otherwise.

summary
MultiSelectSummarySlotProps

Overrides the trigger label region. Receives the default summary text as `summary` — use it as a fallback. Useful when you want to show comma-separated labels (or any other format) instead of the default `"N selected"` for multi-selection states.

suffix
MultiSelectSlotProps

Content rendered after the trigger label. Providing this slot **replaces the default chevron** — render your own fallback when your slot content is conditional. Use `@click.stop` and `@pointerdown.stop` so the press doesn't toggle the popover.

label
{ required: boolean; }

Overrides the rendered label content. Receives `{ required }`.

description

Overrides the rendered description content.

item-prefix
MultiSelectItemSlotProps

Shared content rendered before the standard row label.

item-label
MultiSelectItemSlotProps

Shared content rendered for the standard row label area.

item-suffix
MultiSelectItemSlotProps

Shared content rendered after the standard row label area.

item
MultiSelectItemSlotProps

Replaces the entire row.

group-label
MultiSelectGroupLabelSlotProps

Custom renderer for group labels.

empty
MultiSelectEmptySlotProps

Fallback content rendered when there are no results.

footer
MultiSelectFooterSlotProps

Replaces the default Clear All / Select All footer.

option

Deprecated — compatibility alias for `#item-label`.

update:modelValue
unknown[]

Fired when the model value changes.

update:query
[value: string]

Fired when the query changes.

update:open
unknown[]

Fired when the open state changes.