<script setup lang="ts">
import { ref } from 'vue'
import { MultiSelect } from '@hina-ui/vue'
const tags = ref(['school', 'sf'])
const options = [
{ value: 'school', label: 'School' },
{ value: 'sf', label: 'Sci-fi' },
{ value: 'romance', label: 'Romance' },
{ value: 'mystery', label: 'Mystery' },
{ value: 'fantasy', label: 'Fantasy' },
]
</script>
<template>
<MultiSelect v-model="tags" :options="options" aria-label="Tags" class="w-72" />
</template>
Usage
import { MultiSelect } from '@hina-ui/vue'
The multi-select shares its option data and list with Select, with v-model bound to an array of chosen values. Chosen items show as chips in the trigger, each removable on its own; with clearable set, a button at the end empties them all at once. The list stays open after a pick so several can be ticked in a row.
Current value: none
<script setup lang="ts">
import { ref } from 'vue'
import { MultiSelect, Stack, Text } from '@hina-ui/vue'
const platforms = ref<string[]>([])
const options = [
{ value: 'windows', label: 'Windows' },
{ value: 'switch', label: 'Switch' },
{ value: 'ps5', label: 'PS5' },
{ value: 'android', label: 'Android' },
{ value: 'ios', label: 'iOS' },
]
</script>
<template>
<Stack class="w-72">
<MultiSelect
v-model="platforms"
:options="options"
placeholder="Choose platforms"
aria-label="Platforms"
/>
<Text tone="muted">Current value: {{ platforms.length ? platforms.join(', ') : 'none' }}</Text>
</Stack>
</template>
Examples
Groups
A group carries a label and its options, and can be mixed with plain options.
<script setup lang="ts">
import { ref } from 'vue'
import { MultiSelect } from '@hina-ui/vue'
const languages = ref(['zh'])
const options = [
{ value: 'zh', label: 'Simplified Chinese' },
{
label: 'Japanese',
options: [
{ value: 'ja', label: 'Japanese original' },
{ value: 'ja-tl', label: 'Japanese with translation' },
],
},
{
label: 'Others',
options: [
{ value: 'en', label: 'English' },
{ value: 'ko', label: 'Korean' },
],
},
]
</script>
<template>
<MultiSelect v-model="languages" :options="options" aria-label="Languages" class="w-72" />
</template>
Visible chips
The trigger keeps a fixed height, so only the first maxVisible chips show and the rest collapse into “+N”.
<script setup lang="ts">
import { ref } from 'vue'
import { MultiSelect, Stack } from '@hina-ui/vue'
const tags = ref(['school', 'sf', 'romance', 'mystery'])
const options = [
{ value: 'school', label: 'School' },
{ value: 'sf', label: 'Sci-fi' },
{ value: 'romance', label: 'Romance' },
{ value: 'mystery', label: 'Mystery' },
{ value: 'fantasy', label: 'Fantasy' },
]
</script>
<template>
<Stack class="w-96">
<MultiSelect v-model="tags" :options="options" :max-visible="1" aria-label="At most one" />
<MultiSelect v-model="tags" :options="options" :max-visible="3" aria-label="At most three" />
</Stack>
</template>
Custom content
The option slot customises each row in the list.
The complete option type is inferred from options. Slot parameters preserve additional fields and their types; v-model still binds to an array of option values. See Select for the type definitions.
<script setup lang="ts">
import { ref, type Component } from 'vue'
import { BookOpen, Clapperboard, Gamepad2 } from '@lucide/vue'
import { Inline, MultiSelect } from '@hina-ui/vue'
const types = ref(['gal'])
const options = [
{ value: 'gal', label: 'Galgame' },
{ value: 'ln', label: 'Light novel' },
{ value: 'anime', label: 'Anime' },
]
const icons: Record<string, Component> = { gal: Gamepad2, ln: BookOpen, anime: Clapperboard }
</script>
<template>
<MultiSelect v-model="types" :options="options" aria-label="Work types" class="w-72">
<template #option="{ option }">
<Inline as="span" gap="sm" :wrap="false">
<component :is="icons[option.value]" />
{{ option.label }}
</Inline>
</template>
</MultiSelect>
</template>
Clearable
clearable adds a button at the end that empties everything. Each chip has its own remove button, so it is off by default.
<script setup lang="ts">
import { ref } from 'vue'
import { MultiSelect } from '@hina-ui/vue'
const tags = ref(['school', 'sf'])
const options = [
{ value: 'school', label: 'School' },
{ value: 'sf', label: 'Sci-fi' },
{ value: 'romance', label: 'Romance' },
{ value: 'mystery', label: 'Mystery' },
]
</script>
<template>
<MultiSelect v-model="tags" :options="options" clearable aria-label="Tags" class="w-72" />
</template>
Sizes
Three sizes, matching the input; the chips scale with them.
<script setup lang="ts">
import { MultiSelect, Stack } from '@hina-ui/vue'
const options = [
{ value: 'school', label: 'School' },
{ value: 'sf', label: 'Sci-fi' },
{ value: 'romance', label: 'Romance' },
]
</script>
<template>
<Stack class="w-72">
<MultiSelect size="sm" :options="options" :model-value="['school', 'sf']" aria-label="Small" />
<MultiSelect size="md" :options="options" :model-value="['school', 'sf']" aria-label="Medium" />
<MultiSelect size="lg" :options="options" :model-value="['school', 'sf']" aria-label="Large" />
</Stack>
</template>
Variants
primary sits directly on the page background with a border and shadow; secondary sits inside a surface such as a card, with only a tinted fill.
<script setup lang="ts">
import { Card, MultiSelect, Stack } from '@hina-ui/vue'
const options = [
{ value: 'school', label: 'School' },
{ value: 'sf', label: 'Sci-fi' },
]
</script>
<template>
<Stack class="w-72">
<MultiSelect :options="options" placeholder="Directly on the page" aria-label="On the page" />
<Card>
<MultiSelect
variant="secondary"
:options="options"
placeholder="Inside a card"
aria-label="Inside a card"
/>
</Card>
</Stack>
</template>
States
invalid marks a failed validation and disabled disables the whole control.
<script setup lang="ts">
import { MultiSelect, Stack } from '@hina-ui/vue'
const options = [
{ value: 'school', label: 'School' },
{ value: 'sf', label: 'Sci-fi' },
{ value: 'romance', label: 'Romance' },
]
</script>
<template>
<Stack class="w-72">
<MultiSelect
invalid
:options="options"
placeholder="Choose at least one tag"
aria-label="Invalid"
/>
<MultiSelect disabled :options="options" :model-value="['school']" aria-label="Disabled" />
</Stack>
</template>
In a form
Inside a FormField the label points at the trigger, and the description and error message are rendered by the field; validation rules and submission belong to the Form. The value is an array, so count limits go on the array.
<script setup lang="ts">
import { reactive, ref } from 'vue'
import * as v from 'valibot'
import { Button, Form, FormField, MultiSelect, Text } from '@hina-ui/vue'
const genres = [
{ label: 'Romance', value: 'romance' },
{ label: 'Mystery', value: 'mystery' },
{ label: 'Fantasy', value: 'fantasy' },
{ label: 'Slice of life', value: 'slice-of-life' },
{ label: 'Science fiction', value: 'sci-fi' },
]
const schema = v.object({
genres: v.pipe(
v.array(v.string('Pick genres')),
v.minLength(1, 'Pick at least one genre'),
v.maxLength(3, 'Pick at most three genres'),
),
})
const values = reactive({ genres: [] as Array<string | number> })
const saved = ref('')
async function save(data: unknown) {
await new Promise(resolve => setTimeout(resolve, 600))
saved.value = JSON.stringify(data)
}
</script>
<template>
<Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
<FormField name="genres" label="Genres" description="One to three of them" required>
<MultiSelect v-model="values.genres" :options="genres" />
</FormField>
<Button type="submit" :loading="submitting" class="self-start">Save</Button>
<Text v-if="saved" tone="muted" size="sm">Saved: {{ saved }}</Text>
</Form>
</template>
Virtual scrolling
virtualize renders rows near the viewport, sharing measurement and scrolling with VirtualList. It is off by default. Pass { estimateSize, overscan } to configure estimated row height and the buffer on each side; actual heights are measured. Keyboard navigation covers the full collection and skips disabled items. Rows unmount outside the rendered range; keep persistent slot state outside the row, keyed by its unique value.
Selected: 1
<script setup lang="ts">
import { ref } from 'vue'
import { MultiSelect, Stack, Text } from '@hina-ui/vue'
const selected = ref<Array<string | number>>([7890])
const options = Array.from({ length: 10000 }, (_, index) => ({
value: index,
label: `Item ${String(index + 1).padStart(5, '0')}`,
disabled: index % 97 === 0,
}))
</script>
<template>
<Stack gap="sm" class="w-72 max-w-full">
<MultiSelect
v-model="selected"
:options="options"
:virtualize="{ estimateSize: 36, overscan: 6 }"
aria-label="Ten thousand items"
/>
<Text size="sm" tone="muted">Selected: {{ selected.length }}</Text>
</Stack>
</template>
Behaviour
- Clicking or pressing Enter on an option ticks it and keeps the list open; doing it again unticks it.
- The remove button on a chip and the clear button at the end do not open the list.
- The clear button transitions in and out.
- The list opens against the trigger at the same width, and scrolls inside the panel when it is taller than the space available.
- Page scrolling is locked while open; clicking outside or pressing Esc closes it.
Accessibility
- The trigger is
role="combobox", the list isrole="listbox"and each option isrole="option"witharia-selected. - The remove and clear buttons are named from the locale pack and reachable with Tab.
- Pair it with a
labelelement or anaria-labelfor its name.invalidalso setsaria-invalid.
API
Props
T extends SelectOption is inferred from options and defaults to SelectOption.
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | Array<string | number> | [] | The chosen values |
options | SelectItems<T> | — | The items, see Select |
virtualize | VirtualizeOptions | false | Virtual scrolling; content-based estimate, overscan 6 |
placeholder | string | locale pack | Text shown when nothing is chosen |
maxVisible | number | 2 | Maximum chips shown in the trigger |
clearable | boolean | false | Whether the clear-all button is shown |
open | boolean | false | Whether the list is open; v-model:open |
variant | 'primary' | 'secondary' | 'primary' | Variant |
size | 'sm' | 'md' | 'lg' | 'md' | Size |
invalid | boolean | false | Whether validation failed |
name | string | — | Native form field name |
required | boolean | false | Require a selection when name is set |
autocomplete | string | — | Native form autofill hint |
disabled | boolean | false | Whether the control is disabled |
class | string | — | Classes appended to the trigger |
Slots
| Slot | Payload | Description |
|---|---|---|
option | { option: T } | Content of each row in the list |
Events
| Event | Payload | Description |
|---|---|---|
update:modelValue | value: Array<string | number> | The chosen values changed |
update:open | open: boolean | The list opened or closed |
clear | — | Everything was cleared |
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }