Combobox
A filterable single-select listbox — type to filter, navigate with the keyboard, pick one. It is the
first component driven end-to-end by @oriui/headless: the state machine + prop-getters own the
behaviour (open/close, filtering, the active-descendant highlight, selection, and the full
WAI-ARIA keyboard), and OriCombobox renders the styled shell on top. Unlike the pure-CSS
components, the behaviour needs JavaScript — the standalone classes give you the look, and
useCombobox gives you the behaviour for any UI.
State is expressed on real elements: role="combobox" with aria-expanded / aria-controls /
aria-activedescendant on the input, role="listbox" + role="option" + aria-selected on the
popup. v-model holds the selected option's value.
Classes
The block is a labelled control wrapping a positioned listbox popup. The input reuses
.ori-input__field for its box, so the field look stays in one place.
| Class | Type | Description |
|---|---|---|
ori-combobox | Block | Required base class (wrapper div). |
ori-combobox_* (size) | Size | xs · sm · md · lg · xl · xxl field height. |
ori-color_* | Color | primary · … (focus ring + highlight + selected accent). |
ori-combobox__control · __input · __trigger · __clear · __listbox · __option · __empty | Part | control wrap / input / chevron / clear / popup / option / no-results. |
ori-combobox__label · __required · __hint · __error | Part | label / required-asterisk / helper / error. |
ori-combobox_fluid | Layout | full-width (stretches wrapper to 100 %). |
role=combobox · aria-expanded · aria-activedescendant · aria-selected · data-highlighted | State | real ARIA + data attributes, not classes. |
Basic
Pass options ({ label, value, disabled? }[]) and bind v-model. Type to filter; the list opens
as you type.
- Apple
- Banana
- Cherry
- Grape
- Mango
Type to filter.
<script setup>
import { ref } from 'vue';
const fruit = ref(null);
const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Grape', value: 'grape' },
{ label: 'Mango', value: 'mango' }
];
</script>
<template>
<OriCombobox
v-model="fruit"
:options="options"
label="Fruit"
placeholder="Search a fruit…"
hint="Type to filter."
/>
</template>
Clearable
clearable shows a clear button while there is a selection. Keyboard users clear with Escape when
the listbox is closed (the button itself is a pointer affordance) — so the selection is always removable
without a mouse.
- Apple
- Banana
- Grape
<OriCombobox v-model="fruit" :options="options" label="Fruit" clearable placeholder="Pick one" />
Sizes
xs → xxl, like the other field controls.
- Apple
- Banana
- Apple
- Banana
<OriCombobox v-model="a" :options="options" label="Small" size="sm" />
<OriCombobox v-model="b" :options="options" label="Large" size="lg" />
States
disabled blocks the control; error renders a role="alert" message and flips aria-invalid;
required adds the asterisk and guards the selection — via aria-required plus custom validity, so
typing an unmatched query can't satisfy it and the form is blocked until a real option is chosen. A disabled option is skipped during keyboard
navigation and can't be selected.
- Apple
- Banana
- Apple
- Banana
Please choose a fruit.
<OriCombobox v-model="a" :options="options" label="Disabled" disabled />
<OriCombobox v-model="b" :options="options" label="With error" error="Please choose a fruit." required />
Headless (useCombobox)
The behaviour ships separately in @oriui/headless, so you can build a fully custom combobox UI on
the same state machine + ARIA contract. useCombobox returns ready-to-v-bind prop bags and the
visible (filtered) items.
<script setup>
import { useCombobox } from '@oriui/headless/vue';
const { items, inputProps, listboxProps, getOptionProps, getOptionState } = useCombobox(() => ({
options: [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' }
]
}));
</script>
<template>
<div>
<input v-bind="inputProps" />
<ul v-bind="listboxProps">
<li
v-for="(item, i) in items"
:key="item.value"
v-bind="getOptionProps(item, i)"
:data-selected="getOptionState(item).selected"
>
{{ item.label }}
</li>
</ul>
</div>
</template>
The same behaviour in Svelte 5 via @oriui/headless/svelte — the composable returns stores (auto-subscribe
with $), and the item prop-getters are a store of a function ($getOptionProps(item, i)):
<script>
import { useCombobox } from '@oriui/headless/svelte';
const { items, inputProps, listboxProps, getOptionProps, getOptionState } = useCombobox({
options: [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' }
]
});
</script>
<div>
<input {...$inputProps} />
<ul {...$listboxProps}>
{#each $items as item, i}
<li {...$getOptionProps(item, i)} data-selected={$getOptionState(item).selected}>
{item.label}
</li>
{/each}
</ul>
</div>
Options are a MaybeReactive — pass a Svelte store instead of a plain object to react to a changing
option list or disabled.
Pass a filter ((item, query) => boolean) to override the default case-insensitive substring
match — for fuzzy matching, async results, or server-side filtering.
Accessibility
Implements the WAI-ARIA combobox with listbox popup pattern.
- The input is
role="combobox"witharia-autocomplete="list",aria-expanded, andaria-controlspointing at the listbox; the highlighted option is referenced byaria-activedescendant(focus stays on the input — no roving tabindex needed). - Options are
role="option"witharia-selected; the popup isrole="listbox"labelled by the field label. A disabled option carriesaria-disabledand is skipped by navigation. hint/errorare wired througharia-describedby(error supersedes hint);erroralso setsaria-invalid="true"androle="alert".requiredsetsaria-requiredand blocks submission until a value is selected.- The list dismisses on blur and on
Escape; selecting an option returns focus context to the input.
| Key | Action |
|---|---|
↓ / ↑ | Open the list and move the highlight (wraps). |
Home / End | Highlight the first / last option. |
Enter | Select the highlighted option. |
Escape | Close the list. |
Tab | Move focus away (closes the list). |
| Type | Filter the options. |
Framework API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
clearable | boolean | false | Show a clear button while there is a selection. |
color | ThemeColor | 'primary' | Accent for the focus ring, highlight, and selected option. |
describedby | string | — | Extra id(s) appended to aria-describedby. |
disabled | boolean | false | Disable the control. |
error | string | — | Error message (role="alert"); sets aria-invalid="true". |
filter | (item, query) => boolean | substring | Override the default case-insensitive label filter. |
fluid | boolean | false | Full-width. |
form | string | — | Associate the hidden value input with a form by id. |
hint | string | — | Helper text; hidden while error is shown. |
id | string | — | Explicit base id; auto-generated via useId when omitted. |
invalid | boolean | false | Set aria-invalid without a message. |
label | string | — | Visible label, wired to the input via for / id. |
name | string | — | Submit the selected value under this field name (hidden input). |
noResultsText | string | 'No results' | Shown when the filter matches nothing. |
options | ComboboxItem[] | required | { label, value, disabled? }[]. |
placeholder | string | — | Native placeholder. |
radius | RadiusSize | 'md' | Field + popup corner radius. |
required | boolean | false | Asterisk + aria-required; blocks submit until a value is selected. |
size | ActionSize | 'md' | Field height + text scale. |
v-model
v-model binds the selected option's value (string | null) via defineModel. Selecting an
option emits update:modelValue; clearing emits null.
Slots
| Slot | Scope | Description |
|---|---|---|
label | none | Rich label content (an icon + text, markup). Falls back to the label prop; keeps the for/id + listbox aria-labelledby wiring. |
option | { item, index, selected } | Per-option content — rich options such as an avatar + email or a leading icon. Rendered for every option. Falls back to item.label. |
empty | none | No-results content, shown when the filter matches nothing. Falls back to the noResultsText prop. |