Combobox

A select that filters as you type.

<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>
vue

Usage

import { Combobox } from '@hina-ui/vue'
ts

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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" with aria-autocomplete="list", the list is role="listbox" and each option is role="option" with aria-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 label element or an aria-label for its name. invalid also sets aria-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 }
ts