oriUI

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.

ClassTypeDescription
ori-comboboxBlockRequired base class (wrapper div).
ori-combobox_* (size)Sizexs · sm · md · lg · xl · xxl field height.
ori-color_*Colorprimary · … (focus ring + highlight + selected accent).
ori-combobox__control · __input · __trigger · __clear · __listbox · __option · __emptyPartcontrol wrap / input / chevron / clear / popup / option / no-results.
ori-combobox__label · __required · __hint · __errorPartlabel / required-asterisk / helper / error.
ori-combobox_fluidLayoutfull-width (stretches wrapper to 100 %).
role=combobox · aria-expanded · aria-activedescendant · aria-selected · data-highlightedStatereal ARIA + data attributes, not classes.

Basic

Pass options ({ label, value, disabled? }[]) and bind v-model. Type to filter; the list opens as you type.

Type to filter.

vue
<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>
svelte
<script>
    import { useCombobox } from '@oriui/headless/svelte';

    // The full state machine + WAI-ARIA listbox keyboard — the same engine as Vue, as Svelte stores.
    // (No styled Svelte component yet: compose @oriui/headless/svelte with the .ori-* classes.)
    const { items, rootProps, labelProps, controlProps, inputProps, listboxProps, getOptionProps, getOptionState } =
        useCombobox({
            id: 'fruit',
            options: [
                { label: 'Apple', value: 'apple' },
                { label: 'Banana', value: 'banana' },
                { label: 'Cherry', value: 'cherry' },
                { label: 'Grape', value: 'grape' },
                { label: 'Mango', value: 'mango' }
            ]
        });
</script>

<div {...$rootProps} class="ori-combobox ori-color_primary ori-font-size_md">
    <label {...$labelProps} class="ori-combobox__label">Fruit</label>
    <div {...$controlProps} class="ori-combobox__control">
        <input {...$inputProps} class="ori-input__field ori-combobox__input ori-size-radius_md" placeholder="Search a fruit…" />
        <ul {...$listboxProps} class="ori-combobox__listbox ori-anchored ori-anchored_bottom-start">
            {#each $items as item, i}
                <li
                    {...$getOptionProps(item, i)}
                    class="ori-combobox__option"
                    class:ori-combobox__option_selected={$getOptionState(item).selected}
                >
                    {item.label}
                </li>
            {/each}
        </ul>
    </div>
</div>

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.

vue
<OriCombobox v-model="fruit" :options="options" label="Fruit" clearable placeholder="Pick one" />

Sizes

xsxxl, like the other field controls.

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

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

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

svelte
<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" with aria-autocomplete="list", aria-expanded, and aria-controls pointing at the listbox; the highlighted option is referenced by aria-activedescendant (focus stays on the input — no roving tabindex needed).
  • Options are role="option" with aria-selected; the popup is role="listbox" labelled by the field label. A disabled option carries aria-disabled and is skipped by navigation.
  • hint / error are wired through aria-describedby (error supersedes hint); error also sets aria-invalid="true" and role="alert". required sets aria-required and blocks submission until a value is selected.
  • The list dismisses on blur and on Escape; selecting an option returns focus context to the input.
KeyAction
/ Open the list and move the highlight (wraps).
Home / EndHighlight the first / last option.
EnterSelect the highlighted option.
EscapeClose the list.
TabMove focus away (closes the list).
TypeFilter the options.

Framework API

Props

PropTypeDefaultDescription
clearablebooleanfalseShow a clear button while there is a selection.
colorThemeColor'primary'Accent for the focus ring, highlight, and selected option.
describedbystringExtra id(s) appended to aria-describedby.
disabledbooleanfalseDisable the control.
errorstringError message (role="alert"); sets aria-invalid="true".
filter(item, query) => booleansubstringOverride the default case-insensitive label filter.
fluidbooleanfalseFull-width.
formstringAssociate the hidden value input with a form by id.
hintstringHelper text; hidden while error is shown.
idstringExplicit base id; auto-generated via useId when omitted.
invalidbooleanfalseSet aria-invalid without a message.
labelstringVisible label, wired to the input via for / id.
namestringSubmit the selected value under this field name (hidden input).
noResultsTextstring'No results'Shown when the filter matches nothing.
optionsComboboxItem[]required{ label, value, disabled? }[].
placeholderstringNative placeholder.
radiusRadiusSize'md'Field + popup corner radius.
requiredbooleanfalseAsterisk + aria-required; blocks submit until a value is selected.
sizeActionSize'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

SlotScopeDescription
labelnoneRich 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.
emptynoneNo-results content, shown when the filter matches nothing. Falls back to the noResultsText prop.