MultiSelect
Searchable multi-choice picker. Matches the Combobox / Select item-slot model and provides built-in Clear All / Select All footer controls.
Playground
<MultiSelect
label="Labels"
placeholder="Select labels"
:options="options"
v-model="value"
/>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: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Engineering',
},
{
label: 'Priya Shah',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Design',
},
{
label: 'Marcus Lee',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Product',
},
{
label: 'Sofia Hartmann',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Engineering',
},
{
label: 'Kenji Tanaka',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Design',
},
{
label: 'Nadia Okafor',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Product',
},
]
const value = ref<string[]>(['[email protected]', '[email protected]'])
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-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>Trigger Summary
The trigger reads "N selected" past one selection. Use #summary to render the label region yourself — joined labels, a count with a unit, or anything else. The slot receives the default text as summary, so it doubles as a fallback for the empty state.
<script setup lang="ts">
import { ref } from 'vue'
import { MultiSelect } from 'frappe-ui'
const value = ref<string[]>(['read', 'write'])
const options = [
{ label: 'Read', value: 'read' },
{ label: 'Write', value: 'write' },
{ label: 'Delete', value: 'delete' },
{ label: 'Share', value: 'share' },
]
// The default summary reads "2 selected" past one selection. Joining the
// labels keeps a short, fixed list readable at a glance.
function labelsFor(values: string[]) {
return options
.filter((o) => values.includes(o.value))
.map((o) => o.label)
.join(', ')
}
</script>
<template>
<MultiSelect
v-model="value"
:options="options"
placeholder="Select permissions"
class="w-72"
>
<template #summary="{ summary }">
{{ value.length ? labelsFor(value) : summary }}
</template>
</MultiSelect>
</template>Search Prefix and Suffix
Use #search-prefix and #search-suffix to add content around the popover's search input without changing the trigger slots. Both slots receive { query, setQuery, disabled, focus } — setQuery('') clears the query and focus() moves focus back to the search input. They render inside the search row, so they disappear when hide-search is set.
<script setup lang="ts">
import { ref } from 'vue'
import { MultiSelect } from 'frappe-ui'
const value = ref<string[]>([])
const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Blueberry', value: 'blueberry' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Grape', value: 'grape' },
{ label: 'Orange', value: 'orange' },
]
function clearSearch(setQuery: (value: string) => void, focus: () => void) {
setQuery('')
focus()
}
</script>
<template>
<MultiSelect
v-model="value"
:options="options"
placeholder="Select fruits"
class="w-72"
>
<template #search-prefix>
<span class="lucide-search size-4 shrink-0 text-ink-gray-5" />
</template>
<template #search-suffix="{ query, setQuery, focus }">
<button
v-if="query"
type="button"
aria-label="Clear search"
class="grid size-5 shrink-0 place-items-center rounded-4 text-ink-gray-5 hover:bg-surface-gray-2 hover:text-ink-gray-8"
@pointerdown.prevent
@click="clearSearch(setQuery, focus)"
>
<span class="lucide-x size-3.5" />
</button>
<kbd
v-else
class="shrink-0 flex items-center justify-center rounded-1 size-5 border border-outline-gray-2 bg-surface-gray-1 text-p-xs text-ink-gray-5"
>
F
</kbd>
</template>
</MultiSelect>
</template>Server Search
Fetch options from a server as the user types. Bind v-model:query, debounce the request, and feed the results back into :options. The :loading prop swaps the result body for a loading state. Four things to watch for: pass :filterable="false" so the client doesn't substring-filter what the server already matched, drop stale responses with a request id so a slower earlier query can't overwrite the latest results, merge currently-selected items into the options array so chips stay resolvable after the query narrows the list, and clear the query yourself when the popover opens — see the note below on who owns it.
<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: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Engineering',
},
{
label: 'Alexandra Chen',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Design',
},
{
label: 'Alexei Volkov',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Engineering',
},
{
label: 'Priya Shah',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Design',
},
{
label: 'Priyanka Mehta',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Product',
},
{
label: 'Marcus Lee',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Product',
},
{
label: 'Marco Silva',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Engineering',
},
{
label: 'Maria Garcia',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Marketing',
},
{
label: 'Sofia Hartmann',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Engineering',
},
{
label: 'Sophie Laurent',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Sales',
},
{
label: 'Kenji Tanaka',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Design',
},
{
label: 'Kenta Mori',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Engineering',
},
{
label: 'Nadia Okafor',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Product',
},
{
label: 'Diego Alvarez',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Engineering',
},
{
label: 'Lina Petrova',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Marketing',
},
{
label: 'Liam Connor',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Product',
},
{
label: 'Hassan Iqbal',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Sales',
},
{
label: 'Ava Nguyen',
value: '[email protected]',
image: 'https://i.pravatar.cc/[email protected]',
role: 'Engineering',
},
]
// Mocks a server endpoint: 400ms latency + substring match on label/value.
// A real backend would rank and fuzzy-match, which is why the picker below
// passes `:filterable="false"` — a second client-side substring pass would
// silently drop anything the server matched but the query doesn't contain.
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 query = ref('')
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) return
// Listening for `@update:query` hands ownership of the query over, so the
// open-time reset MultiSelect does for an unbound query is ours to do.
// Without it the last search text stays in the box.
query.value = ''
if (results.value.length === 0) fetchMembers('')
}
</script>
<template>
<!-- The `as` casts on the two handlers below exist because these model
emits are declared twice; drop them when #1096 removes the duplicates. -->
<MultiSelect
v-model="value"
v-model:query="query"
:options="options"
:loading="loading"
:filterable="false"
placeholder="Search members…"
empty-text="No members found"
class="w-80"
@update:query="(q) => onQueryChange(q as string)"
@update:open="(isOpen) => onOpen(isOpen as boolean)"
>
<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>Reading the Selected Options
@update:modelValue gives the selected values. @update:selectedOptions fires alongside it with the original option objects out of :options, so custom fields on your options survive — use it instead of resolving values back to objects yourself.
<MultiSelect
v-model="value"
:options="members"
@update:selectedOptions="(options) => (emails = options.map((o) => o.email))"
/>Custom Footer
Replace the default Clear All / Select All footer with a custom one. The slot receives the shared control props (open, disabled, query, selectedOptions, clear, setOpen) plus selectAll.
<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="{ clear, 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="clear">
<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, query, selectedOptions, clear, and setOpen.
<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, type BadgeProps } from 'frappe-ui'
type Tag = {
label: string
value: string
// Derived from Badge, never hand-written. A copied union is how the
// deprecated `orange` theme outlived the ADR-0008 sweep: nothing failed when
// the real union changed. Note `tsconfig.app.json` excludes `stories/**`, so
// this does not fail CI today — it fails in the editor, and it fails the
// moment stories join the type-check program.
theme: BadgeProps['theme']
}
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: 'amber' },
{ 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 | number) {
tags.value = tags.value.filter((v) => v !== value)
}
</script>
<template>
<MultiSelect v-model="tags" :options="tagOptions">
<template #trigger="{ open, selectedOptions, setOpen }">
<button
type="button"
:data-state="open ? 'open' : 'closed'"
class="flex w-96 min-h-8 cursor-pointer items-center gap-1.5 rounded-4 border border-[--surface-gray-2] px-1.5 py-1 text-left transition-colors hover:border-outline-elevation-2 data-[state=open]:focus-ring"
@click="setOpen(!open)"
>
<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-1 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>Template Ref
A template ref exposes { clear, focus } — the same shape as Select and Combobox. clear() empties the selection and leaves the search query alone; focus() moves focus to the trigger.
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const picker = useTemplateRef('picker')
function reset() {
picker.value?.clear()
picker.value?.focus()
}
</script>
<template>
<MultiSelect ref="picker" v-model="value" :options="options" />
</template>Notes
v-model:queryis optional, but listening for@update:queryalready makes the query yours. There is no observe-only mode: an@update:queryhandler counts as binding it, so the component stops resetting the search box and the last committed text stays in it. If you listen, bindv-model:querytoo and clear it on@update:open. To read the query without owning it, use#search-prefix,#search-suffix, or#footer, which all hand it out.- Use
#item-prefix,#item-label, and#item-suffixto customize the standard option row; reach for#itemonly when you need to replace the row shell too. - For a single choice, use
Comboboxwhen the list needs search andSelectwhen it doesn't.
API Reference
Show types
import type { Component, VNodeChild } from 'vue'
import type { InputLabelingProps } from '../../composables/useInputLabeling'
export type MultiSelectVariant = 'subtle' | 'outline' | 'ghost'
export type MultiSelectSize = 'sm' | 'md' | 'lg' | 'xl'
import type { PopoverSide, PopoverAlign } from '../shared/selection/types'
export type { PopoverSide, PopoverAlign }
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 | number
icon?: string | Component
description?: string
disabled?: boolean
/**
* Dispatches this row to the `#item-<slot>` template slot, e.g.
* `slot: 'member'` renders `#item-member` in the row's label region.
*/
slot?: string
/** Per-item inline slot implementations for the row shell. */
slots?: 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?: Array<string | number>
/** 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
/**
* Controls the in-popover search query. Optional — the component owns the
* query when this is not bound, so `v-model:query` is never required.
*
* When it is bound the consumer owns the query: the component never resets
* it on its own — not on open, not on close, not on mount, not on `clear()`.
* Only typing (or the `setQuery` slot prop) changes it, and a seeded query
* filters the list immediately. Unbound, the query still clears every time
* the popover opens.
*/
query?: string
/** Hides the in-popover search input. */
hideSearch?: boolean
/** Replaces the results with a loading state. */
loading?: boolean
/**
* Client-side query filtering. Defaults to `true`. Set to `false` for
* pickers whose options come from a server search — the backend already
* decided what matches, and a second literal substring pass on the client
* silently drops fuzzy, ranked, or id-based results. This turns off query
* filtering only; nothing else about the component changes.
*/
filterable?: 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. Unset, an embedding host's target is used, else `body`. */
portalTo?: string | HTMLElement
}
/**
* Shared shape for `#trigger`, `#prefix`, `#suffix`, `#footer` (with an added
* `selectAll`), and `#summary` (with an added `summary`). The imperative
* helpers `clear` and `setOpen` 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[]
/** Clears all selected values. It leaves the search query alone. */
clear: () => void
/** Sets the popover open state. */
setOpen: (value: boolean) => void
}
export type MultiSelectTriggerSlotProps = MultiSelectSlotProps
export type MultiSelectPrefixSlotProps = MultiSelectSlotProps
export type MultiSelectSuffixSlotProps = MultiSelectSlotProps
/**
* Props for `#search-prefix` and `#search-suffix`.
*
* Both slots render inside the search row, which only exists while
* `hide-search` is not set — they disappear silently when it is.
*/
export interface MultiSelectSearchSlotProps {
/** Current search query — empty when the user hasn't typed since opening. */
query: string
/** Updates the search query and emits `update:query`. Pass `''` to clear. */
setQuery: (value: string) => void
/** Whether the multi-select is disabled. */
disabled: boolean
/** Moves focus to the search input. */
focus: (options?: FocusOptions) => void
}
/**
* `#footer` gets the shared control shape plus one addition, so it is named
* for the same reason `MultiSelectSummarySlotProps` is: a consumer annotating
* a footer handler needs something to import.
*/
export interface MultiSelectFooterSlotProps extends MultiSelectSlotProps {
/** Selects every enabled option across all groups. */
selectAll: () => void
}
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
}
/**
* Fixed slot names. Kept separate from `MultiSelectSlots` so the dynamic
* `` `item-${string}` `` index signature can be intersected in without
* constraining names that don't match the pattern.
*/
interface MultiSelectFixedSlots {
/** 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
/**
* Content rendered before the in-popover search input. Renders inside the
* search row, so it is not rendered at all when `hide-search` is set.
*/
'search-prefix'?: (props: MultiSelectSearchSlotProps) => any
/**
* Content rendered after the in-popover search input and loading
* indicator. Renders inside the search row, so it is not rendered at all
* when `hide-search` is set.
*/
'search-suffix'?: (props: MultiSelectSearchSlotProps) => 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. Receives the shared
* control slot props plus `selectAll`.
*/
footer?: (props: MultiSelectFooterSlotProps) => any
/** Replaces the entire row. */
item?: (props: MultiSelectItemSlotProps) => any
}
/**
* Item slot names: the three fixed regions of the row shell, the whole-row
* takeover, and any `#item-<name>` dispatched from an option's `slot` field.
*
* The index signature is deliberately narrowed to `` `item-${string}` `` —
* `MultiSelectResults` resolves `option.slot` to `` `item-${option.slot}` ``,
* so this is exactly the runtime behavior, and every fixed slot name stays
* typechecked instead of every typo compiling clean.
*/
interface MultiSelectItemSlotsByName {
/** 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
/** Per-option dynamic slot, selected by the option's `slot` field. */
[slotName: `item-${string}`]:
| ((props: MultiSelectItemSlotProps) => any)
| undefined
}
export interface MultiSelectSlots
extends MultiSelectFixedSlots, MultiSelectItemSlotsByName {}
export interface MultiSelectEmits {
/** Fired when the selection changes. */
'update:modelValue': [value: Array<string | number>]
/**
* Fired alongside `update:modelValue` with the original option objects
* resolved out of `options`, so custom fields on an option survive.
*/
'update:selectedOptions': [value: MultiSelectOption[]]
/** Fired when the open state changes. */
'update:open': [value: boolean]
/** Fired when the search query changes. */
'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.
Controls the in-popover search query. Optional — the component owns the query when this is not bound, so `v-model:query` is never required. When it is bound the consumer owns the query: the component never resets it on its own — not on open, not on close, not on mount, not on `clear()`. Only typing (or the `setQuery` slot prop) changes it, and a seeded query filters the list immediately. Unbound, the query still clears every time the popover opens.
Hides the in-popover search input.
Replaces the results with a loading state.
Client-side query filtering. Defaults to `true`. Set to `false` for pickers whose options come from a server search — the backend already decided what matches, and a second literal substring pass on the client silently drops fuzzy, ranked, or id-based results. This turns off query filtering only; nothing else about the component changes.
Fallback empty-state copy.
Preferred popover side.
Preferred popover alignment.
Gap between trigger and content.
Teleport target for the popover content. Unset, an embedding host's target is used, else `body`.
Label rendered above (or beside, for binary controls) the input.
Helper text rendered below the input. Hidden when `error` is set. A `#description` slot is not: it renders beside the error, and is referenced alongside it.
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, with `sr-only` text that announces it, and forwards `required` / `aria-required` to the underlying control where the control's role allows it. `data-required` is set either way.
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. |
search-prefix | MultiSelectSearchSlotProps Content rendered before the in-popover search input. Renders inside the search row, so it is not rendered at all when `hide-search` is set. |
search-suffix | MultiSelectSearchSlotProps Content rendered after the in-popover search input and loading indicator. Renders inside the search row, so it is not rendered at all when `hide-search` is set. |
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. Receives the shared control slot props plus `selectAll`. |
item | MultiSelectItemSlotProps Replaces the entire row. |
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. |
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.
Content rendered before the in-popover search input. Renders inside the search row, so it is not rendered at all when `hide-search` is set.
Content rendered after the in-popover search input and loading indicator. Renders inside the search row, so it is not rendered at all when `hide-search` is set.
Custom renderer for group labels.
Fallback content rendered when there are no results.
Replaces the default Clear All / Select All footer. Receives the shared control slot props plus `selectAll`.
Replaces the entire row.
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.
| Event | Payload |
|---|---|
update:open | [value: boolean] Fired when the open state changes. |
update:modelValue | [value: (string | number)[]] Fired when the selection changes. |
update:query | [value: string] Fired when the search query changes. |
update:selectedOptions | [value: MultiSelectOption[]] Fired alongside `update:modelValue` with the original option objects resolved out of `options`, so custom fields on an option survive. |
Fired when the open state changes.
Fired when the selection changes.
Fired when the search query changes.
Fired alongside `update:modelValue` with the original option objects resolved out of `options`, so custom fields on an option survive.