Skip to main content
Vuetify0 Logo
Theme
Mode
Palettes
Accessibility
Vuetify One
Sign in to Vuetify One

Access premium tools across the Vuetify ecosystem — Bin, Play, Studio, and more.

Not a subscriber? See what's included

createCombobox

Low-level combobox coordinator for custom implementations. Most users should use the Combobox component instead.

Edit
Report a Bug
Issues
Source
Markdown

PreviewIntermediateSep 10, 2026

Usage

ts
import { createCombobox } from '@vuetify/v0'

const combobox = createCombobox({ strict: true })

// Register items with the underlying selection
combobox.selection.register({ id: 'apple', value: 'Apple' })
combobox.selection.register({ id: 'banana', value: 'Banana' })
combobox.selection.register({ id: 'cherry', value: 'Cherry' })

// Open the dropdown
combobox.open()

// Select an item — in single mode this updates display and closes
combobox.select('banana')
// combobox.display.value === 'Banana'
// combobox.isOpen.value === false

// Filter is pristine after selection — all items still visible
// combobox.pristine.value === true

// Once user types, the filter activates
combobox.query.value = 'ch'
// combobox.pristine.value === false
// combobox.filtered.value === Set { 'cherry' }

Context / DI

Context Object

createCombobox returns a ComboboxContext with the following API surface:

MemberTypeDescription
open()() => voidOpens the dropdown
close()() => voidCloses and resets query/pristine (always discards uncommitted text; does not commit)
toggle()() => voidOpens or closes
commit()() => voidCommits typed text (exact browse match, or mint when !strict); empty, pristine, or strict-unmatched just close
select(id)(id: ID) => voidSelects an item by ID
clear()() => voidResets query and deselects all
idstringBase ID used for ARIA relationships
inputIdstring${id}-input
listboxIdstring${id}-listbox
multipleReadonly<Ref<boolean>>Multiple-select flag
strictMaybeRefOrGetter<boolean>Strict option ref
disabledMaybeRefOrGetter<boolean>Disabled option ref
namestring | undefinedForm field name
formstring | undefinedAssociated form ID

Dependency Injection

Use createComboboxContext to get a DI-aware trinity for component-based setups:

ts
import { createComboboxContext, useCombobox } from '@vuetify/v0'

// In a Root component
const [useMyCombobox, provideMyCombobox, context] = createComboboxContext({
  namespace: 'my-combobox',
  strict: true,
})
provideMyCombobox(context)

// In any child component
const combobox = useCombobox('my-combobox')

useCombobox(namespace?) injects the nearest combobox context (default namespace: 'v0:combobox').

Adapters

Adapters extend ComboboxAdapter and translate a reactive query into a filtered ID set.

ts
abstract class ComboboxAdapter {
  abstract setup (context: ComboboxAdapterContext): ComboboxAdapterResult
}

interface ComboboxAdapterResult {
  filtered: Ref<Set<ID>>          // IDs that should be visible
  isLoading: ShallowRef<boolean>  // shows loading state in the UI
  isEmpty: Ref<boolean>           // true when no items match the query
}

The context exposes { query, items }, where items is the registered ticket list (ad-hoc free-text tickets excluded). Return the three refs above and the combobox wires them to the dropdown state automatically.

ClientComboboxAdapter

The default. Filters registered items locally using substring matching (case-insensitive). Pass custom filter options to override the matching logic:

ts
import { ClientComboboxAdapter, createCombobox } from '@vuetify/v0'

const combobox = createCombobox({
  adapter: new ClientComboboxAdapter({
    filter: (query, value) => String(value).toLowerCase().startsWith(query.toLowerCase()),
  }),
})

ServerComboboxAdapter

A pass-through adapter that shows all registered items and sets isLoading to false. Use this when filtering is performed server-side — watch combobox.query to drive your own fetch:

ts
import { ServerComboboxAdapter, createCombobox, useCombobox } from '@vuetify/v0'
import { watch } from 'vue'

const combobox = createCombobox({ adapter: new ServerComboboxAdapter() })

// In a component that injects the context:
const { query } = useCombobox()

watch(query, async q => {
  const results = await fetch(`/api/search?q=${q}`).then(r => r.json())
  // Update items via combobox.selection.register / unregister
})
Tip

See the Combobox server example for a complete integration.

Dropdown placement is a separate seam: pass positionAdapter (a PopoverAdapter) rather than reusing adapter. Default is CSS anchor positioning; swap in FloatingUIPopoverAdapter from @vuetify/v0/popover/adapters/floating-ui the same way as usePopover.

Architecture

createCombobox orchestrates four independent primitives without extending their chains — it composes them. The adapter translates queries into a filtered set; virtual focus uses that set to skip hidden items.

createCombobox Architecture

Use controls to zoom and pan. Click outside or press Escape to close.

createCombobox Architecture

Options

ts
interface ComboboxOptions {
  multiple?: MaybeRefOrGetter<boolean>   // Enable multi-select
  mandatory?: MaybeRefOrGetter<boolean>  // Prevent deselecting last item
  disabled?: MaybeRefOrGetter<boolean>   // Disable all interaction
  strict?: MaybeRefOrGetter<boolean>     // Constrain accepted values to registered options; unmatched text is discarded on confirm (Enter/Tab/click-outside/blur)
  adapter?: ComboboxAdapter              // Filtering strategy (default: ClientComboboxAdapter)
  positionAdapter?: PopoverAdapter       // Dropdown positioning engine (default: V0PopoverAdapter)
  displayValue?: (value: unknown) => string  // Format selected value for display in input
  id?: string                            // Base ID for ARIA attributes
  name?: string                          // Hidden input name for form submission
  form?: string                          // Associated form ID
}

Reactivity

PropertyTypeReactiveNotes
queryShallowRef<string>Current input text
pristineShallowRef<boolean>true after selection; false once user types
filteredRef<Set<ID>>IDs that pass the current filter
isEmptyRef<boolean>true when filtered set is empty
isLoadingShallowRef<boolean>Forwarded from adapter
isOpenShallowRef<boolean>Popover open state
selectionSelectionContext—Full selection API
popoverPopoverReturn—Popover positioning API
cursorVirtualFocusReturn—Keyboard focus API
inputElShallowRef<HTMLElement | null>Reference to the <input> element

Examples

Country Autocomplete

A fully custom country picker built directly on createCombobox with the default ClientComboboxAdapter. The composable registers a dozen countries into the underlying selection registry with strict: true, so unmatched text is discarded on confirm; the adapter filters the visible set on every keystroke, and cursor (the useVirtualFocus surface) tracks the keyboard-highlighted row independently of real DOM focus. A separate panel mirrors the confirmed selection, so the example shows both halves of an autocomplete: the typeahead input and the value display it feeds.

State and view are split deliberately. useCountrySearch owns the data and the coordinated state — it creates a createComboboxContext({ namespace: 'v0:country-combobox', strict: true }) trinity, registers countries, and returns the context plus a selected getter derived from selection.selectedIds. The parent calls provideCountryCombobox; CountryAutocomplete injects via useCountryCombobox. The composable never touches events; the component wires onInput, onKeydown, and @focus to drive the context. Arrow keys call cursor.next() / cursor.prev(). Enter reads cursor.highlightedId and routes to select(id), or to commit() when nothing is highlighted. Tab does the same accept path — selecting a highlighted unselected option, otherwise commit() — then close() so the listbox doesn’t linger; it does not preventDefault, so focus can move on. Pointer dismiss uses useClickOutside to commit() then close() (query only, ignoring leftover virtual focus). Escape and any other close() discard uncommitted text. Closing without commit still reverts the input via pristine + display falling back to the last selected ticket.

ARIA wiring is manual but mechanical: role="combobox", aria-controls, aria-expanded, aria-autocomplete="both" (strict), and aria-activedescendant are set on the input from the IDs the context vends (inputId, listboxId, id), and each option row carries role="option", aria-selected, and a stable per-option id so screen readers can correlate the highlighted row. Reach for this approach when you need full control over the markup; prefer the Combobox component when the defaults suffice, and see createSelection for the selection layer underneath.

FileRole
useCountrySearch.tsCreates the combobox, registers countries, derives the selected country
CountryAutocomplete.vueRenders the input and listbox; owns the keyboard and input events
country-autocomplete.vueWires the composable to the component and shows the selected-value panel
United StatesUS
CanadaCA
BrazilBR
United KingdomGB
FranceFR
GermanyDE
SpainES
NigeriaNG
South AfricaZA
JapanJP
IndiaIN
AustraliaAU

Selected country

None yet


Theme
Mode
Palettes
Accessibility

Recipes

Strict Mode

strict constrains accepted values to registered options. Confirming with Enter, Tab, click-outside, or blur:

  • Non-strict (default): commit() mints a ticket for unmatched text, selects it, and emits it.

  • Strict: unmatched text is discarded; the dropdown closes.

close() and Escape always discard uncommitted text — they never commit. After close, pristine is true so display falls back to the selected ticket’s label (or '' if nothing is selected).

Pristine Flag

pristine tracks whether the query reflects the current selection or is a live filter:

  • Starts as true (no user input yet).

  • Becomes false when the user types — the adapter receives the raw query.

  • Resets to true after select(id) or commit(), and query is cleared, so the adapter sees an empty query and unfilters. Reopening the dropdown always shows all items.

Multi-Select Behavior

In multiple mode, select(id) differs from single mode:

  • Toggles the item (select → deselect on second click) via selection.toggle().

  • Clears the query so the user can search for the next item.

  • Keeps the dropdown open.

  • Highlights the clicked item via cursor.highlight(id) so ArrowDown continues from that position.

  • Refocuses the input so keyboard navigation continues immediately.

Virtualized listbox

Keep every ticket registered. Window the DOM with createVirtual. Write ticket.el from the row, then scrollTo before cursor.highlight so off-screen arrows land on a real node.

Theme
Mode
Palettes
Accessibility
virtual-listbox.vue
<script setup lang="ts">
  import { createCombobox, createVirtual, isUndefined } from '@vuetify/v0'
  import { computed, nextTick, useTemplateRef, watch } from 'vue'

  const HEIGHT = 36
  const combobox = createCombobox({ strict: true })
  const input = useTemplateRef<HTMLInputElement>('input')

  for (let index = 0; index < 2000; index++) {
    combobox.selection.register({
      id: `city-${index}`,
      value: `City ${String(index + 1).padStart(4, '0')}`,
    })
  }

  const rows = computed(() => {
    const ids = combobox.filtered.value
    return combobox.selection.values()
      .filter(ticket => ids.has(ticket.id))
      .map(ticket => ({
        id: String(ticket.id),
        label: String(ticket.value ?? ''),
      }))
  })

  const virtual = createVirtual(rows, { itemHeight: HEIGHT })
  const { element, items, offset, size, scroll, scrollTo } = virtual

  watch(input, el => {
    combobox.inputEl.value = el
  }, { immediate: true })

  watch(() => combobox.cursor.highlightedId.value, id => {
    if (isUndefined(id)) return
    const index = rows.value.findIndex(row => row.id === id)
    if (index === -1) return
    scrollTo(index, { block: 'nearest' })
    nextTick(() => combobox.cursor.highlight(id))
  })

  function bindEl (id: string, node: unknown) {
    const ticket = combobox.selection.get(id)
    if (isUndefined(ticket)) return
    ticket.el = node instanceof HTMLElement ? node : null
  }

  function onInput (event: Event) {
    combobox.query.value = (event.target as HTMLInputElement).value
    combobox.pristine.value = false
  }

  function onKeydown (event: KeyboardEvent) {
    if (event.isComposing) return
    switch (event.key) {
      case 'ArrowDown': {
        event.preventDefault()
        combobox.cursor.next()

        break
      }
      case 'ArrowUp': {
        event.preventDefault()
        combobox.cursor.prev()

        break
      }
      case 'Enter': {
        event.preventDefault()
        const id = combobox.cursor.highlightedId.value
        if (!isUndefined(id)) combobox.select(id)

        break
      }
    // No default
    }
  }
</script>

<template>
  <div class="max-w-xs mx-auto flex flex-col gap-2">
    <input
      ref="input"
      :aria-activedescendant="combobox.cursor.highlightedId.value ? `${combobox.id}-option-${combobox.cursor.highlightedId.value}` : undefined"
      aria-autocomplete="both"
      :aria-controls="combobox.listboxId"
      aria-expanded="true"
      autocomplete="off"
      class="w-full px-3 py-2 rounded-lg border border-divider bg-surface text-on-surface text-sm placeholder:text-on-surface-variant focus:border-primary focus:outline-none"
      placeholder="Search 2,000 cities…"
      role="combobox"
      :value="combobox.display.value"
      @input="onInput"
      @keydown="onKeydown"
    >

    <div
      :id="combobox.listboxId"
      ref="element"
      class="h-[240px] overflow-y-auto p-1 rounded-lg border border-divider bg-surface"
      role="listbox"
      @scroll="scroll"
    >
      <div :style="{ height: `${offset}px` }" />

      <div
        v-for="item in items"
        :id="`${combobox.id}-option-${item.raw.id}`"
        :key="item.raw.id"
        :ref="node => bindEl(item.raw.id, node)"
        :aria-selected="combobox.selection.selected(item.raw.id)"
        class="flex items-center px-3 rounded-md cursor-default select-none text-sm text-on-surface hover:bg-surface-variant data-[selected]:font-medium data-[selected]:text-primary data-[highlighted]:bg-primary data-[highlighted]:text-on-primary"
        :data-highlighted="combobox.cursor.highlightedId.value === item.raw.id || undefined"
        :data-selected="combobox.selection.selected(item.raw.id) || undefined"
        role="option"
        :style="{ height: `${HEIGHT}px` }"
        @click="combobox.select(item.raw.id)"
        @pointerenter="combobox.cursor.highlight(item.raw.id)"
      >
        {{ item.raw.label }}
      </div>

      <div :style="{ height: `${size}px` }" />
    </div>
  </div>
</template>

FAQ

Discord
Need help? Join our community for support and discussions ↗

API Reference

The following API details are for the createCombobox composable.
Was this page helpful?

© 2016-1970 Vuetify, LLC
Services
Ctrl+/