Skip to content

Combobox Component ​

The Combobox component provides a powerful searchable select input with autocomplete functionality built with Vue 3 and Headless UI. It supports local options, asynchronous data loading, single/multiple selection, custom rendering, and various customization options.

Installation ​

To use the Combobox component you need to install the @vueuse/core and @headlessui/vue packages.

bash
yarn add @robuust-digital/vue-components @vueuse/core @headlessui/vue

Import the component from the combobox package ​

js
import { Combobox } from '@robuust-digital/vue-components/combobox';

Import CSS ​

css
@import "@robuust-digital/vue-components/combobox/css";

Basic Usage ​

vue
<template>
  <Combobox
    v-model="single"
    id="basic-example"
    :on-search="mockSearch"
    placeholder="Search fruits..."
  />
</template>

Local Options ​

Pass an options array to enable local mode. Options are loaded on focus, and typing filters them using their optionText. Enable open-on-focus to also open the list when the input receives focus. No endpoint or onSearch callback is required.

vue
<template>
  <Combobox
    v-model="local"
    id="local-example"
    :options="mockItems"
    placeholder="Search fruits..."
    open-on-focus
  />
</template>

When both options and onSearch are supplied, local options take precedence. The minLength, debounce, minLoadingTime, endpoint, and cancellation props only affect remote searches.

Opening on Focus ​

Set open-on-focus to open the results when the input receives focus by clicking or tabbing into it:

vue
<Combobox
  id="focus-example"
  v-model="selected"
  :options="items"
  open-on-focus
/>

The default is false. This also works with grouped options and asynchronous onSearch results. Focusing the input opens and loads results through the same behavior as clicking the dropdown button. Existing searchOnly and minimum query length rules still apply. Focus restored from within the combobox does not reopen it after closing or selecting an option.

Option Groups ​

Enable grouped to supply categories as the top-level array. Each category has a unique string or numeric value, a heading in label, and its selectable items in options by default. Set group-items-key to read another property. This example uses items:

js
const categories = [
  {
    value: 1,
    label: 'Category A',
    items: [
      { id: 1, label: 'Item x' },
      { id: 2, label: 'Item y' },
    ],
  },
  {
    value: 2,
    label: 'Category B',
    items: [
      { id: 3, label: 'Item z' },
    ],
  },
  {
    value: 3,
    label: 'Category C',
    items: [
      { id: 4, label: 'Item ...' },
    ],
  },
];
vue
<Combobox
  id="grouped-example"
  v-model="selectedGroupedItems"
  :options="categories"
  :display-value="(option) => option?.label ?? ''"
  :option-text="(option) => option.label"
  grouped
  group-items-key="items"
  multiple
/>

Categories and their items retain the supplied order. Local search filters items within each category using optionText and hides categories without matching items. With asynchronous onSearch, return the same category structure after responseData is applied; the server handles filtering. groupItemsKey applies to both local options and asynchronous results. Empty categories are hidden in both modes. Without grouped, options and search results remain ordinary flat item arrays.

These are accessible listbox groups (role="group" with aria-labelledby), rather than native <optgroup> elements, which belong inside <select>. Group labels are separate, non-selectable headings. Arrow keys navigate only through options, including across groups. Selected values retain their original item objects. Item keys must be unique across all categories because selection uses itemKey.

Use #groupLabel="{ label, value, options }" to customize a heading without changing the option markup. The slot's options prop contains the category's items regardless of groupItemsKey. Keep heading content non-interactive.

API Integration Example ​

Real-world example using JSONPlaceholder API to search users:

vue
<template>
  <Combobox
    v-model="apiUsers"
    id="api-example"
    endpoint="users"
    :on-search="comboboxSearch"
    :display-value="customDisplayValue"
    :option-text="customOptionText"
    placeholder="Search users..."
    clearable
  />
</template>

<script setup>
/**
 * API search example
 * 
 * @param {String} searchValue - Search query
 * @param {Object} params - Additional request params
 * @param {String} endpoint - API endpoint
 */
const comboboxSearch = async (searchValue, params, endpoint) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/${endpoint}${searchValue ? `?username=${searchValue}` : ''}`
  );

  if (response.ok) {
    const data = await response.json();
    return { data };
  }

  return { data: [], error: 'An error occurred' };
};
</script>

Props ​

PropTypeDefaultDescription
idStringRequiredUnique identifier for the combobox
modelValueAnynullv-model binding value
optionsArrayundefinedOptions to display and filter locally
groupedBooleanfalseRead options and search results as categories with value, label, and an item array configured by groupItemsKey
groupItemsKeyString'options'Property containing the item array in each category when grouped
openOnFocusBooleanfalseOpen results when the input receives focus from outside the combobox
endpointStringundefinedAPI endpoint for search requests
requestParamsObject{}Additional params for API requests
onSearchFunction() => Promise.resolve({ data: [] })Callback used for remote searches
manualInputBooleanfalseAllow manual input without selection
responseDataFunctiondata => dataTransform API response data
displayValueFunctionitem => item?.nameFormat selected value display
optionTextFunctionitem => item?.nameFormat option text in dropdown
noResultsTextString'No results found.'Message displayed when no results are found
disabledBooleanfalseDisable the combobox
minLengthNumber2Minimum characters before search
itemKeyString'id'Unique key for options
clearableBooleanfalseShow clear button
multipleBooleanfalseEnable multiple selection
searchOnlyBooleanfalseSearch-only mode without selection
iconObject, FunctionnullCustom icon component
prefixIconObject, FunctionnullCustom left icon component
sizeString'base'Size variant ('sm' or 'base')
debounceNumber150Debounce time for search in milliseconds
minLoadingTimeNumber0Minimum loading indicator display time
onCancelFunctionnullCallback to cancel pending requests

Events ​

EventPayloadDescription
update:modelValueAnyEmitted when selection changes
update:requestParamsObjectEmitted when request params change
combobox:noResultsStringEmitted when search returns no results
combobox:errorObject, StringEmitted when search encounters an error

Advanced Examples ​

Empty-result Message ​

Use noResultsText to customize the message for both local options and remote searches:

vue
<Combobox
  id="localized-example"
  :options="mockItems"
  no-results-text="Geen resultaten gevonden."
/>

To set the message for all comboboxes, configure combobox.noResultsText in your application configuration:

ts
import { defineRvcConfig } from '@robuust-digital/vue-components/rvc-config';

export default defineRvcConfig({
  combobox: {
    noResultsText: 'Geen resultaten gevonden.',
  },
});

An explicit noResultsText prop overrides the application configuration. Without either override, the message remains No results found..

Multiple Selection with Custom Display ​

vue
<template>
  <Combobox
    v-model="multiple"
    id="multiple-custom"
    :on-search="comboboxSearch"
    endpoint="users"
    multiple
    clearable
    :display-value="customDisplayValue"
    :option-text="customOptionText"
    placeholder="Select multiple users..."
  >
    <template #chip="{ optionText, option, removeOption }">
      <Badge color="blue" size="sm">
        {{ optionText }}
        <button @click="removeOption(option)">×</button>
      </Badge>
    </template>
  </Combobox>
</template>

Custom Option Rendering ​

vue
<template>
  <Combobox
    v-model="single"
    id="custom-option"
    :on-search="comboboxSearch"
    endpoint="users"
    clearable
  >
    <template #option="{ option }">
      <div class="flex flex-col">
        <strong>{{ option.username }}</strong>
        <small class="text-slate-500">{{ option.email }}</small>
      </div>
    </template>
  </Combobox>
</template>

Selected-value Suffix ​

Use selectedSuffix to add metadata next to a single selected value, before the dropdown button. Its option prop is the full selected object, including your custom fields. The dropdown arrow and loading indicator keep their existing slots.

The suffix is rendered only when a selection exists and the dropdown is closed. It is hidden while searching, after clearing, and in multiple mode; use chip to customize selected items in multiple mode. Without selectedSuffix, the field keeps its existing layout.

vue
<script setup>
import { ref } from 'vue';
import { Combobox } from '@robuust-digital/vue-components/combobox';

/**
 * Resources with availability metadata.
 *
 * @type {Array<{ id: number, name: string, available: boolean }>}
 */
const resources = [
  { id: 1, name: 'Resource A', available: true },
  { id: 2, name: 'Resource B', available: false },
];
/**
 * Currently selected resource.
 *
 * @type {import('vue').Ref<object | null>}
 */
const selectedResource = ref(resources[0]);
</script>

<template>
  <Combobox
    v-model="selectedResource"
    id="resource-status"
    :options="resources"
    clearable
  >
    <template #selectedSuffix="{ option }">
      <span
        class="size-2 shrink-0 rounded-full"
        :class="option.available ? 'bg-green-500' : 'bg-slate-400'"
        role="img"
        :aria-label="option.available ? 'Available' : 'Unavailable'"
      />
    </template>
  </Combobox>
</template>

Request Cancellation ​

Example showing how to cancel pending requests when a new search is initiated:

vue
<template>
  <Combobox
    v-model="single"
    id="cancel-example"
    :on-search="searchWithCancel"
    :on-cancel="(controller) => controller.abort()"
    placeholder="Search with cancellation..."
  />
</template>

<script setup>
/**
 * API search example with request cancellation
 * 
 * @param {String} searchValue - Search query
 * @param {Object} params - Additional request params
 * @param {String} endpoint - API endpoint
 */
const searchWithCancel = async (searchValue, params, endpoint) => {
  const controller = new AbortController();
  const { signal } = controller;

  const response = await fetch(`https://jsonplaceholder.typicode.com/${endpoint}${searchValue ? `?username=${searchValue}` : ''}`, { signal });
  const data = await response.json();

  return {
    data,
    cancel: controller,
  };
};
</script>

This example demonstrates how to implement request cancellation when a new search is initiated before the previous one completes.

Slots ​

SlotPropsDescription
prefixIcon{ icon }Custom left icon
selectedSuffix{ option }Metadata for the full selected object in a closed, single-select field
icon{ icon }Custom right icon
spinner{ spinning }Custom loading indicator
clear-Custom clear button
chip{ optionText, option, removeOption }Custom chip for multiple selection
groupLabel{ label, value, options }Non-selectable group heading
option{ option, isActive }Custom option rendering
optionSuffix{ option, isActive }Additional option metadata
optionPrefix{ option, isActive }Prefix metadata for options

Improved Documentation

We are planning to provide more examples and advanced use cases for the Combobox component. Stay tuned!

CSS Customization ⚡️ ​

To customize the combobox styles global

css
:root {
  /* Available variables */
  --rvc-combobox-border-radius: var(--rvc-base-border-radius);
  --rvc-combobox-border-width: var(--rvc-base-border-width);
  --rvc-combobox-border-color: var(--rvc-base-border-color);
  --rvc-combobox-font-size: var(--rvc-base-input-font-size);
  --rvc-combobox-font-weight: var(--rvc-base-input-font-weight);
  --rvc-combobox-box-shadow: var(--rvc-base-box-shadow);
  --rvc-combobox-color: var(--rvc-base-input-color);
  --rvc-combobox-bg-color: var(--rvc-base-input-bg-color);
  --rvc-combobox-bg-color-disabled: var(--rvc-base-input-bg-color-disabled);
  --rvc-combobox-disabled-opacity: var(--rvc-base-input-disabled-opacity);
  --rvc-combobox-padding-x: var(--rvc-base-input-padding-x);
  --rvc-combobox-height: var(--rvc-base-input-height);
  --rvc-combobox-icon-size: var(--rvc-base-input-icon-size);
  --rvc-combobox-icon-color: var(--rvc-base-input-icon-color);
  --rvc-combobox-icon-loading-animation: var(--rvc-base-loading-animation);
  --rvc-combobox-placeholder-color: var(--rvc-base-input-placeholder-color);
  --rvc-combobox-clear-color: var(--rvc-base-input-icon-color);
  --rvc-combobox-clear-color-hover: var(--color-slate-700);
  --rvc-combobox-chip-icon-size: calc(var(--spacing) * 4);
  --rvc-combobox-chip-color: var(--rvc-base-input-color);
  --rvc-combobox-chip-color-hover: var(--color-slate-700);
  --rvc-combobox-chips-spacing: calc(var(--spacing) * 2);
  --rvc-combobox-options-offset: calc(var(--spacing) * 1);
  --rvc-combobox-options-z-index: 10;
  --rvc-combobox-options-max-height: calc(var(--spacing) * 60);
  --rvc-combobox-options-padding-x: 0.1875rem;
  --rvc-combobox-options-padding-y: 0.1875rem;
  --rvc-combobox-option-padding-x: calc(var(--spacing) * 2);
  --rvc-combobox-option-padding-y: calc(var(--spacing) * 1.5);
  --rvc-combobox-option-bg-color-hover: var(--color-slate-100);
  --rvc-combobox-option-bg-color-active: transparent;
  --rvc-combobox-option-color-active: var(--rvc-base-input-color);
  --rvc-combobox-option-font-weight-active: var(--font-weight-semibold);
  --rvc-combobox-group-label-color: var(--color-slate-500);
  --rvc-combobox-group-label-font-size: calc(var(--rvc-combobox-font-size) * 0.85);
  --rvc-combobox-group-label-font-weight: var(--font-weight-semibold);

  /* Small variant */
  --rvc-combobox-height-sm: 1.875rem;
  --rvc-combobox-font-size-sm: calc(var(--rvc-base-input-font-size) * 0.9);
  --rvc-combobox-padding-x-sm: calc(var(--rvc-base-input-padding-x) * 0.85);
  --rvc-combobox-icon-size-sm: calc(var(--rvc-base-input-icon-size) * 0.85);
}

Roadmap ​

  • Add more examples and advanced use cases
  • Improve Docs with more detailed explanations
  • Implement infinite loading for large datasets