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.
yarn add @robuust-digital/vue-components @vueuse/core @headlessui/vueImport the component from the combobox package
import { Combobox } from '@robuust-digital/vue-components/combobox';Import CSS
@import "@robuust-digital/vue-components/combobox/css";Basic Usage
<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.
<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:
<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:
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 ...' },
],
},
];<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:
<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
| Prop | Type | Default | Description |
|---|---|---|---|
| id | String | Required | Unique identifier for the combobox |
| modelValue | Any | null | v-model binding value |
| options | Array | undefined | Options to display and filter locally |
| grouped | Boolean | false | Read options and search results as categories with value, label, and an item array configured by groupItemsKey |
| groupItemsKey | String | 'options' | Property containing the item array in each category when grouped |
| openOnFocus | Boolean | false | Open results when the input receives focus from outside the combobox |
| endpoint | String | undefined | API endpoint for search requests |
| requestParams | Object | {} | Additional params for API requests |
| onSearch | Function | () => Promise.resolve({ data: [] }) | Callback used for remote searches |
| manualInput | Boolean | false | Allow manual input without selection |
| responseData | Function | data => data | Transform API response data |
| displayValue | Function | item => item?.name | Format selected value display |
| optionText | Function | item => item?.name | Format option text in dropdown |
| noResultsText | String | 'No results found.' | Message displayed when no results are found |
| disabled | Boolean | false | Disable the combobox |
| minLength | Number | 2 | Minimum characters before search |
| itemKey | String | 'id' | Unique key for options |
| clearable | Boolean | false | Show clear button |
| multiple | Boolean | false | Enable multiple selection |
| searchOnly | Boolean | false | Search-only mode without selection |
| icon | Object, Function | null | Custom icon component |
| prefixIcon | Object, Function | null | Custom left icon component |
| size | String | 'base' | Size variant ('sm' or 'base') |
| debounce | Number | 150 | Debounce time for search in milliseconds |
| minLoadingTime | Number | 0 | Minimum loading indicator display time |
| onCancel | Function | null | Callback to cancel pending requests |
Events
| Event | Payload | Description |
|---|---|---|
| update:modelValue | Any | Emitted when selection changes |
| update:requestParams | Object | Emitted when request params change |
| combobox:noResults | String | Emitted when search returns no results |
| combobox:error | Object, String | Emitted when search encounters an error |
Advanced Examples
Empty-result Message
Use noResultsText to customize the message for both local options and remote searches:
<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:
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
<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
<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.
<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:
<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
| Slot | Props | Description |
|---|---|---|
| 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
: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