<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from '@hina-ui/vue'
const work = ref<string | null>(null)
const works = [
{ value: 'summer-pockets', label: 'Summer Pockets' },
{ value: 'clannad', label: 'CLANNAD' },
{ value: 'little-busters', label: 'Little Busters!' },
{ value: 'rewrite', label: 'Rewrite' },
{ value: 'air', label: 'AIR' },
{ value: 'kanon', label: 'Kanon' },
]
</script>
<template>
<Combobox
v-model="work"
:options="works"
placeholder="Search works"
aria-label="Work"
class="w-64"
/>
</template>
Usage
import { Combobox } from '@hina-ui/vue'
The combobox pairs a text field with a floating list and shares its option data with Select. v-model binds the chosen value, typing filters the list by option label, and the button at the end opens or closes the list. The text area shares the input’s field surface; attributes it does not declare land on the inner input.
Current value: key
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox, Stack, Text } from '@hina-ui/vue'
const studio = ref<string | null>('key')
const studios = [
{ value: 'key', label: 'Key' },
{ value: 'type-moon', label: 'TYPE-MOON' },
{ value: 'august', label: 'August' },
{ value: 'saga-planets', label: 'SAGA PLANETS' },
{ value: 'yuzusoft', label: 'ゆずソフト' },
]
</script>
<template>
<Stack class="w-64">
<Combobox v-model="studio" :options="studios" aria-label="Studio" />
<Text tone="muted">Current value: {{ studio ?? 'none' }}</Text>
</Stack>
</template>
The complete option type is inferred from options. Slot parameters preserve additional fields and their types; v-model still binds to value. See Select for the type definitions.
Examples
Groups
A group carries a label and its options; groups left empty by the filter are hidden with it.
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from '@hina-ui/vue'
const tag = ref<string | null>(null)
const tags = [
{
label: 'Theme',
options: [
{ value: 'school', label: 'School' },
{ value: 'sf', label: 'Sci-fi' },
{ value: 'fantasy', label: 'Fantasy' },
],
},
{
label: 'Form',
options: [
{ value: 'kinetic', label: 'Kinetic' },
{ value: 'branch', label: 'Branching' },
],
},
]
</script>
<template>
<Combobox
v-model="tag"
:options="tags"
placeholder="Search tags"
aria-label="Tags"
class="w-64"
/>
</template>
Remote data
ignoreFilter disables local filtering, and v-model:search provides the input text. Pass the search results directly to options; the dropdown renders only those results.
selectedOption supplies the selected item separately. Its value must match v-model to provide the displayed name; it neither adds a candidate nor changes the selection. Names are remembered when results are replaced or cleared. Asynchronously supplied or updated names are reflected without overwriting an active search; closing the list restores the latest name. When both sources contain the same value, selectedOption takes precedence for the displayed selection name.
loading replaces the toggle arrow with a loading indicator while keeping typing, selection and clearing available. Request state and debouncing are controlled by the caller.
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.
Results: 0 · Value: 31
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { refDebounced, useFetch } from '@vueuse/core'
import { useRuntimeConfig } from '#app'
import { Combobox, Stack, Text } from '@hina-ui/vue'
interface TagPage {
data: { items: Array<{ id: number; name: string; nameEn: string }> }
}
const selectedOption = { value: 31, label: 'Light novels' }
const selected = ref<string | number | null>(selectedOption.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-64">
<Combobox
v-model="selected"
v-model:search="search"
:options="options"
:selected-option="selectedOption"
:loading="isFetching"
ignore-filter
clearable
placeholder="Search tags"
aria-label="Tag"
/>
<Text tone="muted">Results: {{ options.length }} · Value: {{ selected ?? 'none' }}</Text>
</Stack>
</template>
Clearable
clearable shows a clear button while there is a value; clicking it empties both the value and the text.
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox } from '@hina-ui/vue'
const work = ref<string | null>('clannad')
const works = [
{ value: 'summer-pockets', label: 'Summer Pockets' },
{ value: 'clannad', label: 'CLANNAD' },
{ value: 'rewrite', label: 'Rewrite' },
]
</script>
<template>
<Combobox v-model="work" :options="works" clearable aria-label="Work" class="w-72" />
</template>
Sizes
Three sizes, matching the input.
<script setup lang="ts">
import { Combobox, Stack } from '@hina-ui/vue'
const options = [
{ value: 'gal', label: 'Galgame' },
{ value: 'ln', label: 'Light novel' },
]
</script>
<template>
<Stack class="w-64">
<Combobox size="sm" :options="options" model-value="gal" aria-label="Small" />
<Combobox size="md" :options="options" model-value="gal" aria-label="Medium" />
<Combobox size="lg" :options="options" model-value="gal" 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, Combobox, Stack } from '@hina-ui/vue'
const options = [
{ value: 'gal', label: 'Galgame' },
{ value: 'ln', label: 'Light novel' },
]
</script>
<template>
<Stack class="w-64">
<Combobox :options="options" placeholder="Directly on the page" aria-label="On the page" />
<Card>
<Combobox
variant="secondary"
:options="options"
placeholder="Inside a card"
aria-label="Inside a card"
/>
</Card>
</Stack>
</template>
States
invalid marks a failed validation, disabled disables the whole combobox, and disabled on an option disables that option only.
<script setup lang="ts">
import { Combobox, Stack } from '@hina-ui/vue'
const options = [
{ value: 'gal', label: 'Galgame' },
{ value: 'ln', label: 'Light novel' },
{ value: 'manga', label: 'Manga', disabled: true },
]
</script>
<template>
<Stack class="w-64">
<Combobox invalid :options="options" placeholder="Choose a type" aria-label="Invalid" />
<Combobox disabled :options="options" model-value="gal" aria-label="Disabled" />
</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 and linked to it; validation rules and submission belong to the Form.
<script setup lang="ts">
import { reactive, ref } from 'vue'
import * as v from 'valibot'
import { Button, Combobox, Form, FormField, Text } from '@hina-ui/vue'
const cities = [
{ label: 'Tokyo', value: 'tokyo' },
{ label: 'Osaka', value: 'osaka' },
{ label: 'Kyoto', value: 'kyoto' },
{ label: 'Sapporo', value: 'sapporo' },
{ label: 'Fukuoka', value: 'fukuoka' },
]
const schema = v.object({
city: v.pipe(v.string('Pick a city'), v.nonEmpty('Pick a city')),
})
const values = reactive({ city: null as string | number | null })
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="city" label="City" required>
<Combobox v-model="values.city" :options="cities" />
</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: 7890
<script setup lang="ts">
import { ref } from 'vue'
import { Combobox, Stack, Text } from '@hina-ui/vue'
const selected = ref<string | number | null>(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-64 max-w-full">
<Combobox
v-model="selected"
:options="options"
:virtualize="{ estimateSize: 36, overscan: 6 }"
aria-label="Ten thousand items"
/>
<Text size="sm" tone="muted">Selected: {{ selected }}</Text>
</Stack>
</template>
Behaviour
- Clicking the text area or pressing an arrow key opens the list; typing filters it by option label, and a hint shows when nothing matches.
- Choosing an option closes the list and fills the text with its label; on blur, text that was not chosen reverts to the chosen option, while deleting all the text clears the value.
- The list opens against the text area at the same width, and scrolls inside the panel when it is taller than the space available.
- The toggle and clear buttons do not blur the text area.
Accessibility
- The text area is
role="combobox"witharia-autocomplete="list", the list isrole="listbox"and each option isrole="option"witharia-selected. - The toggle button is outside the Tab order and named from the locale pack.
- While loading, the field surface has
aria-busy="true". - Pair it with a
labelelement or anaria-labelfor its name.invalidalso setsaria-invalid.
API
Props
T extends SelectOption is inferred from options and selectedOption, and defaults to SelectOption.
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | number | null | — | The chosen value |
options | SelectItems<T> | — | The items, see Select |
virtualize | VirtualizeOptions | false | Virtual scrolling; content-based estimate, overscan 6 |
selectedOption | T | null | — | Selected item data for display only; not added to the dropdown |
search | string | '' | The current text; v-model:search |
placeholder | string | locale pack | Text shown when nothing is chosen |
ignoreFilter | boolean | false | Whether filtering is left to the caller |
loading | boolean | false | Whether to show the loading indicator |
clearable | boolean | false | Whether the clear 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 |
disabled | boolean | false | Whether the combobox is disabled |
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: string | number | The chosen value changed |
update:search | search: string | The text changed |
update:open | open: boolean | The list opened or closed |
clear | — | The value was cleared |
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }