<script setup lang="ts">
import { ref } from 'vue'
import { MultiCombobox } from '@hina-ui/vue'
const studios = ref<Array<string | number>>([1])
const options = [
{ value: 1, label: 'Key' },
{ value: 2, label: 'Type-Moon' },
{ value: 3, label: 'Nitroplus' },
{ value: 4, label: 'Leaf' },
{ value: 5, label: 'Frontwing' },
{ value: 6, label: 'Yuzusoft' },
]
</script>
<template>
<MultiCombobox v-model="studios" :options="options" aria-label="Studios" class="w-80" />
</template>
Usage
import { MultiCombobox } from '@hina-ui/vue'
The multi combobox is the multi-value form of Combobox: type in the field to narrow the list, pick several items, and the chosen ones sit as chips before the text input, wrapping when they no longer fit. v-model binds an array of chosen values and options uses the types defined by Select. Attributes it does not declare land on the inner text input, so name it with aria-label or aria-labelledby.
Nothing chosen yet
<script setup lang="ts">
import { ref } from 'vue'
import { MultiCombobox, Stack, Text } from '@hina-ui/vue'
const genres = ref<Array<string | number>>([])
const options = [
{ value: 'school', label: 'School' },
{ value: 'sf', label: 'Science fiction' },
{ value: 'romance', label: 'Romance' },
{ value: 'mystery', label: 'Mystery' },
{ value: 'fantasy', label: 'Fantasy' },
{ value: 'daily', label: 'Slice of life' },
]
</script>
<template>
<Stack gap="sm" class="w-full max-w-sm">
<MultiCombobox
v-model="genres"
:options="options"
placeholder="Type a genre"
aria-label="Genres"
/>
<Text tone="muted" size="sm">
{{ genres.length ? genres.join(', ') : 'Nothing chosen yet' }}
</Text>
</Stack>
</template>
It differs from MultiSelect in whether you type: a multi select picks from a fixed list and locks page scrolling while open; a multi combobox narrows the list by typing and never locks scrolling, so the field stays editable. It differs from TagsInput in where values come from: a tags input accepts any text, here every value must come from the options.
Examples
Remote search
ignoreFilter disables local filtering, and v-model:search provides the input text. Pass remote search results directly to options. Supply selected item data separately through selectedOptions; each Chip resolves its name from the values in v-model. This data neither adds dropdown candidates nor selects additional values.
Names are remembered when search results are replaced or cleared. Asynchronously supplied or updated names are reflected in the chips. When both sources contain the same value, selectedOptions takes precedence for the chip name. A selected value returned by the search still appears as a checked candidate.
The example prefills three tags before requesting search results. Debouncing and cancelling requests belong to the caller; loading replaces the toggle arrow with a loading indicator.
The example requests a static JSON file shipped with the docs and simulates filtering in the caller, without a server proxy. Replace the request URL and result mapping when connecting a search API.
Selected: 3 · Results: 0
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { refDebounced, useFetch } from '@vueuse/core'
import { useRuntimeConfig } from '#app'
import { Inline, MultiCombobox, Stack, Text } from '@hina-ui/vue'
interface TagPage {
data: { items: Array<{ id: number; name: string; nameEn: string }> }
}
const selectedTags = [
{ value: 31, label: 'Light novels' },
{ value: 32, label: 'Tappei Nagatsuki' },
{ value: 33, label: 'Time travel' },
]
const selected = ref<Array<string | number>>(selectedTags.map(tag => tag.value))
const baseURL = useRuntimeConfig().app.baseURL
const search = ref('')
const keyword = refDebounced(search, 300)
const url = computed(
() => `${baseURL}demo/tags.json?${new URLSearchParams({ search: keyword.value })}`,
)
const { data, isFetching, execute } = useFetch(url, {
refetch: true,
immediate: false,
}).json<TagPage>()
const options = computed(
() =>
data.value?.data.items
.filter(tag =>
tag.nameEn.toLocaleLowerCase().includes(keyword.value.trim().toLocaleLowerCase()),
)
.slice(0, 10)
.map(tag => ({ value: tag.id, label: tag.nameEn })) ?? [],
)
onMounted(() => execute())
</script>
<template>
<Stack class="w-80">
<MultiCombobox
v-model="selected"
v-model:search="search"
:options="options"
:selected-options="selectedTags"
:loading="isFetching"
ignore-filter
placeholder="Search tags"
aria-label="Tags"
>
<template #option="{ option }">
<Inline gap="sm" align="center" :wrap="false" class="min-w-0">
<Text as="span" class="truncate">{{ option.label }}</Text>
<Text as="span" tone="muted" size="xs" class="ms-auto shrink-0 font-mono">
#{{ option.value }}
</Text>
</Inline>
</template>
</MultiCombobox>
<Text tone="muted">Selected: {{ selected.length }} · Results: {{ options.length }}</Text>
</Stack>
</template>
Custom content
The option slot customises each row in the list, for example with a cover and an id.
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 } from 'vue'
import { Inline, MultiCombobox, Text } from '@hina-ui/vue'
const tags = ref<Array<string | number>>([12])
const options = [
{ value: 12, label: 'School', description: '1,204 works' },
{ value: 34, label: 'Science fiction', description: '388 works' },
{ value: 56, label: 'Romance', description: '2,019 works' },
{ value: 78, label: 'Mystery', description: '271 works' },
]
</script>
<template>
<MultiCombobox v-model="tags" :options="options" aria-label="Tags" class="w-80">
<template #option="{ option }">
<Inline gap="sm" align="center" :wrap="false" class="min-w-0">
<Text as="span" class="truncate">{{ option.label }}</Text>
<Text as="span" tone="muted" size="xs" class="ms-auto shrink-0 font-mono">
#{{ option.value }}
</Text>
</Inline>
</template>
</MultiCombobox>
</template>
Clearable
clearable adds a button at the end that empties everything and returns focus to the input. Each chip has its own remove button, so it is off by default.
<script setup lang="ts">
import { ref } from 'vue'
import { MultiCombobox } from '@hina-ui/vue'
const studios = ref<Array<string | number>>([1, 2])
const options = [
{ value: 1, label: 'Key' },
{ value: 2, label: 'Type-Moon' },
{ value: 3, label: 'Nitroplus' },
]
</script>
<template>
<MultiCombobox v-model="studios" :options="options" clearable aria-label="Studios" class="w-80" />
</template>
Sizes
size has three steps, sm, md and lg. With nothing chosen the height equals an input of the same step.
<script setup lang="ts">
import { MultiCombobox, Stack } from '@hina-ui/vue'
const options = [
{ value: 1, label: 'Key' },
{ value: 2, label: 'Type-Moon' },
]
</script>
<template>
<Stack gap="sm" class="w-full max-w-sm">
<MultiCombobox size="sm" :options="options" :model-value="[1]" aria-label="Small" />
<MultiCombobox size="md" :options="options" :model-value="[1]" aria-label="Medium" />
<MultiCombobox size="lg" :options="options" :model-value="[1]" aria-label="Large" />
</Stack>
</template>
States
invalid gives the field a warning color and disabled disables the group. variant="secondary" is the flat form used inside a surface.
<script setup lang="ts">
import { MultiCombobox, Stack } from '@hina-ui/vue'
const options = [
{ value: 1, label: 'Key' },
{ value: 2, label: 'Type-Moon' },
]
</script>
<template>
<Stack gap="sm" class="w-full max-w-sm">
<MultiCombobox :options="options" :model-value="[1]" invalid aria-label="Invalid" />
<MultiCombobox :options="options" :model-value="[1]" disabled aria-label="Disabled" />
<MultiCombobox
:options="options"
:model-value="[1]"
variant="secondary"
aria-label="Secondary"
/>
</Stack>
</template>
In a form
Inside a FormField the label points at the input, 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, MultiCombobox, Text } from '@hina-ui/vue'
const tags = [
{ label: 'Romance', value: 'romance' },
{ label: 'Romantic comedy', value: 'rom-com' },
{ label: 'School', value: 'school' },
{ label: 'Isekai', value: 'isekai' },
{ label: 'Healing', value: 'healing' },
{ label: 'Mystery', value: 'mystery' },
]
const schema = v.object({
tags: v.pipe(
v.array(v.string('Pick tags')),
v.minLength(1, 'Pick at least one tag'),
v.maxLength(3, 'Pick at most three tags'),
),
})
const values = reactive({ tags: [] 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="tags" label="Tags" description="One to three of them" required>
<MultiCombobox v-model="values.tags" :options="tags" />
</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. Search still processes the full dataset. 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 { MultiCombobox, 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-80 max-w-full">
<MultiCombobox
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>
Behavior
- Typing opens the list and filters it; the arrow keys move the highlight, Enter ticks or unticks the highlighted item, the list stays open and the typed text and its filtering stay put, so several items from one query can be ticked in a row; clearing the input brings the whole list back.
- Clicking the blank area of the field or the text of a chip focuses the input and opens the list, the same as clicking the input.
- With an empty input, Backspace removes the last chip.
- Esc or a click outside closes the list. Page scrolling is never locked.
- Chosen items stay ticked in the list; picking one again unticks it.
Accessibility
- The text input is
role="combobox", the list isrole="listbox"and each option isrole="option"witharia-selected. - Name the text input with
aria-labeloraria-labelledby; the remove, clear and toggle buttons carry names from the locale. invalidalso setsaria-invalidon the text input.
API
Props
T extends SelectOption is inferred from options and selectedOptions, 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 |
selectedOptions | T[] | — | Selected item data for chip labels only; not added to the dropdown |
placeholder | string | locale pack | Placeholder of the input while nothing is chosen |
search | string | '' | Text in the input; v-model:search |
ignoreFilter | boolean | false | Whether to skip local filtering for remote search |
loading | boolean | false | Whether the toggle arrow shows a spinner |
clearable | boolean | false | Whether the clear-all button is shown |
name | string | — | Form field name |
open | boolean | false | Whether the list is open; v-model:open |
variant | 'primary' | 'secondary' | 'primary' | Variant |
size | 'sm' | 'md' | 'lg' | 'md' | Size |
disabled | boolean | false | Whether the group is disabled |
invalid | boolean | false | Whether the group failed validation |
class | string | — | Classes appended to the root element |
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:search | value: string | The typed text changed |
update:open | open: boolean | The list opened or closed |
clear | — | Everything was cleared |
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }