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.
<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.
<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.
<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.
<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.
<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>Custom Footer
Replace the default Clear All / Select All footer with a custom one. The slot receives clearAll, selectAll, selectedOptions, and query.
<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.
<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.
<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.
<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
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]
}Array of selected option values.
Options rendered in the popover.
Visual style of the trigger.
Size of the trigger and option rows.
Placeholder text shown when no value is selected.
Disables the multi-select.
Controls the popover visibility.
Hides the in-popover search input.
Replaces the results with a loading state.
Fallback empty-state copy.
Preferred popover side.
Preferred popover alignment.
Gap between trigger and content.
Teleport target for the popover content.
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 rendered above (or beside, for binary controls) the input.
Helper text rendered below the input. Hidden when `error` is set.
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).
Marks the field as required. Renders an asterisk next to the label and forwards `required` / `aria-required` to the underlying control.
HTML id of the underlying control. Auto-generated via `useId()` if omitted.
| Slot | Payload |
|---|---|
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`. |
Fully custom trigger renderer.
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.
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.
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.
Overrides the rendered label content. Receives `{ required }`.
Overrides the rendered description content.
Shared content rendered before the standard row label.
Shared content rendered for the standard row label area.
Shared content rendered after the standard row label area.
Replaces the entire row.
Custom renderer for group labels.
Fallback content rendered when there are no results.
Replaces the default Clear All / Select All footer.
Deprecated — compatibility alias for `#item-label`.
| Event | Payload |
|---|---|
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. |
Fired when the model value changes.
Fired when the query changes.
Fired when the open state changes.