Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

Combobox

A combo box is a text input with a list of suggestions under it: typing filters the options, and choosing one puts its text in the input and makes its key the value. Use it instead of <input list> with a <datalist> when the options need markup, groups or a styled popup, or when the value isn’t the text. Combobox is an Autocomplete, a Listbox behind a text input, with its list in a Popover. Focus stays in the input the whole time; the highlighted option is the input’s aria-activedescendant.

It follows the WAI-ARIA Combobox pattern, as in its list autocomplete example.

Combobox
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The combobox's look is @uniaria/styles' default. */
:root {
  font:
    15px/1.5 system-ui,
    sans-serif;
}
body {
  display: grid;
  gap: 1rem;
  margin: 1.5rem;
  background: var(--uni-surface-color);
  color: var(--uni-text-color);
}
.row {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  align-items: end;
}
.row > uni-combobox {
  inline-size: 16rem;
}
output {
  padding-block: 0.5rem;
}
</style><div class="row">
  <uni-combobox disabled-keys='["durian"]'>
    <div data-uni="combobox">
      <label data-element="Label">Fruit</label>
      <div data-element="Anchor">
        <input data-element="Input" placeholder="Type to search" />
        <button data-element="Button"></button>
      </div>
      <div data-element="Popover">
        <div data-element="List">
          <div data-element="Option" data-key="apple">Apple</div>
          <div data-element="Option" data-key="apricot">Apricot</div>
          <div data-element="Option" data-key="banana">Banana</div>
          <div data-element="Option" data-key="blueberry">Blueberry</div>
          <div data-element="Option" data-key="cherry">Cherry</div>
          <div data-element="Option" data-key="durian">Durian</div>
          <div data-element="Option" data-key="grape">Grape</div>
          <div data-element="Option" data-key="kiwi">Kiwi</div>
          <div data-element="Option" data-key="lemon">Lemon</div>
          <div data-element="Option" data-key="mango">Mango</div>
          <div data-element="Option" data-key="orange">Orange</div>
          <div data-element="Option" data-key="peach">Peach</div>
          <div data-element="Option" data-key="pear">Pear</div>
        </div>
        <p data-element="Empty">No fruit matches.</p>
      </div>
      <input data-element="HiddenInput" name="fruit" />
    </div>
  </uni-combobox>
  <output id="log">Type “an”, then use the arrows and Enter.</output>
</div>
<script src="/demo/combobox.js"></script><script>const log = document.getElementById('log');
let value = [];
let text = '';

const show = () => {
  log.textContent = `value: [${value.join(', ')}] · text: “${text}”`;
};

// The value settles on a choice, Enter or blur; the text changes as you type.
document.addEventListener('valuechange', (event) => {
  value = event.detail;
  show();
});
document.addEventListener('inputchange', (event) => {
  text = event.detail;
  show();
});
</script>

Usage

React

import { combobox } from '@uniaria/core/combobox';
import { useBehavior } from '@uniaria/react';

const fruits = [
  { key: 'apple', textValue: 'Apple' },
  { key: 'banana', textValue: 'Banana' },
  { key: 'cherry', textValue: 'Cherry' },
];

export function FruitCombobox() {
  const fruit = useBehavior(combobox, { items: fruits });
  return (
    <div {...fruit.parts.root}>
      <label {...fruit.parts.label}>Fruit</label>
      <div {...fruit.parts.anchor}>
        <input {...fruit.parts.input} />
        <button {...fruit.parts.button}>
          <ChevronIcon aria-hidden />
        </button>
      </div>
      <div {...fruit.parts.popover}>
        <div {...fruit.parts.list}>
          {fruits.map((item) => (
            <div key={item.key} {...fruit.getItemProps('option', item.key)}>
              {item.textValue}
            </div>
          ))}
        </div>
        <p {...fruit.parts.empty}>No fruit matches</p>
      </div>
      <input {...fruit.parts.hiddenInput} name="fruit" />
    </div>
  );
}

Vue

<script setup lang="ts">
import { combobox } from '@uniaria/core/combobox';
import { useBehavior } from '@uniaria/vue';

const fruits = [
  { key: 'apple', textValue: 'Apple' },
  { key: 'banana', textValue: 'Banana' },
  { key: 'cherry', textValue: 'Cherry' },
];

const fruit = useBehavior(combobox, { items: fruits });
</script>

<template>
  <div v-bind="fruit.parts.root">
    <label v-bind="fruit.parts.label">Fruit</label>
    <div v-bind="fruit.parts.anchor">
      <input v-bind="fruit.parts.input" />
      <button v-bind="fruit.parts.button">
        <ChevronIcon aria-hidden="true" />
      </button>
    </div>
    <div v-bind="fruit.parts.popover">
      <div v-bind="fruit.parts.list">
        <div
          v-for="item in fruits"
          :key="item.key"
          v-bind="fruit.getItemProps('option', item.key)"
        >
          {{ item.textValue }}
        </div>
      </div>
      <p v-bind="fruit.parts.empty">No fruit matches</p>
    </div>
    <input v-bind="fruit.parts.hiddenInput" name="fruit" />
  </div>
</template>

HTML

<uni-combobox>
  <div data-uni="combobox">
    <label data-element="Label">Fruit</label>
    <div data-element="Anchor">
      <input data-element="Input" />
      <button data-element="Button"><svg aria-hidden="true">…</svg></button>
    </div>
    <div data-element="Popover">
      <div data-element="List">
        <div data-element="Option" data-key="apple">Apple</div>
        <div data-element="Option" data-key="banana">Banana</div>
        <div data-element="Option" data-key="cherry">Cherry</div>
      </div>
      <p data-element="Empty">No fruit matches</p>
    </div>
    <input data-element="HiddenInput" name="fruit" />
  </div>
</uni-combobox>

<script type="module">
  import '@uniaria/elements/combobox';
</script>

Render every option, matching or not: UniAria hides the ones the typed text leaves out. The options are Listbox’s item parts, so keys, groups, option labels, descriptions and extras, and items read from the markup work as on Listbox.

Render the root part around everything, the popover included. Combobox tracks focus within it: when focus leaves the root, the typed text settles and the list closes. Without a root, the text never settles on blur and focuschange never fires.

Filtering

By default an option matches when its text contains the typed text, trimmed, ignoring case and accents, in the field’s language: locale, else the nearest lang attribute, else the browser’s. Group labels aren’t searched; a group with no match left is hidden with its label.

  • When it applies. Only after the user types. The text a chosen option wrote filters nothing, so opening the list after a choice shows every option. After typing, opening the list shows the matches, or every option when nothing matches.
  • What it hides. Options and groups left out get the hidden attribute, the arrow keys skip them, and they don’t count in the option count. If your CSS sets display on options, keep [hidden] hiding them.
  • No matches. isEmpty and data-empty are set. With an empty part, the list stays open and the part describes it; without one, the list closes until something matches.

Pass your own filter to match differently. It gets each option’s text, the trimmed input text, and the item as items holds it, so it can match more than the text, such as keywords or a description:

const startsWith = (textValue: string, inputValue: string) =>
  textValue.toLowerCase().startsWith(inputValue.toLowerCase());

const byKeyword = (
  textValue: string,
  inputValue: string,
  item: { keywords: readonly string[] },
) => item.keywords.includes(inputValue.toLowerCase());

filter: false shows items as you give them, for filtering on a server from inputchange:

const [query, setQuery] = useState('');
const cities = useCitySearch(query); // your data
const city = useBehavior(combobox, {
  items: cities,
  filter: false,
  onInputChange: setQuery,
});

On the element, filter is a property: element.filter = startsWith.

Choosing and settling

Typing changes only the text and reports inputchange. The value changes when the user chooses an option, or when the text settles.

  • Choosing an option, with a press or Enter on the highlighted one, selects it, writes its text into the input and closes the list. Focus stays in the input. Choosing the selected option again just closes the list.
  • The text settles on Enter with nothing highlighted, and when focus leaves the root. In order:
    1. Text equal to an enabled option’s text, ignoring case and accents, selects that option.
    2. After typing, a text that exactly one enabled option matches selects it: “kiw” selects Kiwi.
    3. Blank text clears the value.
    4. With allowsCustomValue, the trimmed text becomes the value.
    5. Otherwise the text goes back to the selected option’s text, or to empty.

Enter with nothing highlighted isn’t prevented, so a form around the combo box submits with the settled value.

Custom values

With allowsCustomValue (allows-custom-value), text that matches no option becomes the value when it settles: typing “Mango smoothie” and pressing Enter gives value: ['Mango smoothie'], which the hidden input submits. Text that names an option still selects the option.

Opening

popoverTrigger Opens the list
'input' Typing non-blank text, the button, ArrowDown and ArrowUp.
'focus' The same, and focusing the input.
'manual' Only the button, ArrowDown and ArrowUp.

Forms

Put your name, and form if needed, on the hiddenInput part. UniAria makes it type="hidden" and keeps its value at the selected key, or the custom text, else empty.

  • With isRequired, the visible input gets the native required: its text is empty exactly when the value is, so the browser blocks an empty submit and focuses the input.
  • Resetting the form goes back to defaultValue and its text, even a defaultValue set after the combo box rendered, and valueChange reports it. A controlled combo box gets a valueChange asking for defaultValue, or without one the value it mounted with.

Controlled value and text

React

const [value, setValue] = useState<readonly string[]>([]);
const [inputValue, setInputValue] = useState('');
const fruit = useBehavior(combobox, {
  items: fruits,
  value,
  onValueChange: setValue,
  inputValue,
  onInputChange: setInputValue,
});

// A clear button of your own.
const clear = () => {
  setValue([]);
  setInputValue('');
};

Vue

<script setup lang="ts">
const value = ref<readonly string[]>([]);
const inputValue = ref('');
const fruit = useBehavior(combobox, () => ({
  items: fruits,
  value: value.value,
  onValueChange: (keys) => (value.value = keys),
  inputValue: inputValue.value,
  onInputChange: (text) => (inputValue.value = text ?? ''),
}));

// A clear button of your own.
function clear() {
  value.value = [];
  inputValue.value = '';
}
</script>

HTML

const fruit = document.querySelector('uni-combobox');
fruit.value = [];
fruit.addEventListener('valuechange', (event) => {
  fruit.value = event.detail;
});

Setting the value, inputValue or isOpen property makes it controlled. Set defaultValue, defaultInputValue or defaultOpen to let the element keep its own. Reading the property gives the current value either way: inputValue reads the input’s text.

Without inputValue or defaultInputValue, the text starts as the selected option’s text.

Translations

The button’s name and the option count are English by default. Replace them whole:

const fruit = useBehavior(combobox, {
  items: fruits,
  buttonLabel: 'Vorschläge anzeigen',
  optionCountAnnouncement: (count) =>
    count === 1 ? '1 Option verfügbar' : `${count} Optionen verfügbar`,
});

On the element, buttonLabel is the button-label attribute and optionCountAnnouncement a property.

Virtualization

Combobox takes Listbox’s virtualization props and passes them to its list. Put the scroller part inside the popover, around the list, and render the renderedKeys state as on Listbox. While the user filters, renderedKeys holds only matching options. Give the popover a width, such as width: var(--anchor-width), since the rows have none of their own.

Option tooltips

Combobox takes Listbox’s option tooltip: one Tooltip with shouldOpenOnlyWhenTruncated and relation: 'none', which shows the whole text of a cut-off option. Pass its instance as optionTooltip, or on the element name it by id with the option-tooltip attribute, and render it inside the popover, after the list.

React

const full = useBehavior(tooltip, {
  shouldOpenOnlyWhenTruncated: true,
  relation: 'none',
  positionArea: 'right',
});
const fruit = useBehavior(combobox, {
  items: fruits,
  optionTooltip: full.instance,
});
return (
  <div {...fruit.parts.root}>
    …
    <div {...fruit.parts.popover}>
      <div {...fruit.parts.list}>…</div>
      <div {...full.parts.root}>
        <span {...full.parts.text} />
      </div>
    </div>
  </div>
);

Vue

<script setup lang="ts">
const full = useBehavior(tooltip, {
  shouldOpenOnlyWhenTruncated: true,
  relation: 'none',
  positionArea: 'right',
});
const fruit = useBehavior(combobox, {
  items: fruits,
  optionTooltip: full.instance,
});
</script>

<template>
  <div v-bind="fruit.parts.root">
    …
    <div v-bind="fruit.parts.popover">
      <div v-bind="fruit.parts.list">…</div>
      <div v-bind="full.parts.root">
        <span v-bind="full.parts.text" />
      </div>
    </div>
  </div>
</template>

HTML

<uni-combobox option-tooltip="fruit-full">
  <div data-uni="combobox">
    …
    <div data-element="Popover">
      <div data-element="List">…</div>
      <uni-tooltip
        id="fruit-full"
        open-only-when-truncated
        relation="none"
        position-area="right"
      >
        <div data-uni="tooltip"><span data-element="Text"></span></div>
      </uni-tooltip>
    </div>
  </div>
</uni-combobox>

It follows the visible highlight, which the keys move, and the pointer. Typing or editing the text closes it until a key moves the highlight again.

Combobox takes no tooltip for its input: an input scrolls its text instead of cutting it.

Anatomy

Part Element Notes
root any, usually div Around every other part. Focus leaving it settles the text and closes the list.
label label by default Names the input and the list. UniAria writes its for, so a click focuses the input.
anchor any, usually div Optional. The box around the input and the button: the popover is placed against it and --anchor-width is its width. Without it, the input. Extras beside the input, such as a count or a button of yours, go in it too, as a prefix or a suffix goes in TextField’s wrapper.
input input The text. type is yours (text or search); UniAria writes the value property.
button button Optional. Opens and closes the list without taking focus. Its icon is yours, hidden with aria-hidden.
popover any, usually div Always rendered, after the input. Hidden while closed.
arrow any, inside the popover Optional. Points at the anchor.
scroller any, usually div Only when virtualized: the box that scrolls, around the list.
list any, usually div or ul Inside the popover. Holds the options and groups.
empty any Optional, inside the popover and outside the list. Shown while no option matches, and keeps the list open then.
description any Optional. Describes the input.
error any Optional. Shown and announced while isInvalid.
errorText any, inside error Optional. In a Form, UniAria writes the first error’s text here; leave it out to render errors yourself.
hiddenInput input Optional. Submits the value; UniAria makes it type="hidden" and writes its value.

A part keeps an id you give it, and everything that refers to the part follows it; UniAria writes an id only on a part that has none (see Ids).

The item parts are Listbox’s: option, optionLabel, optionDescription, optionExtras, group and groupLabel, inside the list and keyed by the item’s key (see Listbox). They carry no tabindex, since they never take focus.

Props

Prop Attribute Type Default Description
items, getKey, getTextValue, isItemDisabled, getChildren — (properties, or markup) Listbox’s: every option, unfiltered, and how to read them. getTextValue is also what the filter matches and what the input shows once chosen.
disabledKeys disabled-keys (JSON) readonly string[] [] Disabled options, by key. They’re filtered like the others.
value / defaultValue — (properties) readonly string[] [] The selected key, or the custom text: at most one. Changes only when the text settles, never per keystroke.
inputValue / defaultInputValue — (properties) string the selected option’s text The input’s text. Changes are reported by inputchange.
isOpen / defaultOpen — / default-open boolean false Whether the list is open.
popoverTrigger popover-trigger 'input' | 'focus' | 'manual' 'input' What opens the list besides the button and the arrows.
filter — (property) ((textValue: string, inputValue: string, item: Item) => boolean) | false contains Which options match the typed text. false shows every item.
allowsCustomValue allows-custom-value boolean false Text that matches no option becomes the value when it settles.
isDisabled disabled boolean false The input and the button are natively disabled.
isReadOnly readonly boolean false The input is natively read-only. The list opens from the button and the arrows and the highlight moves, but nothing is selected.
isRequired required boolean false aria-required and the native required on the input.
isInvalid invalid boolean — aria-invalid on the input, and shows the error part. Unset in a Form, the form’s errors decide it.
shouldFocusWrap focus-wrap boolean false The arrows wrap from the last option to the first.
positionArea position-area PositionArea 'block-end span-inline-end' Where the popover goes: below the field, aligned to its start edge. See Popover.
positionTryFallbacks position-try-fallbacks string 'flip-block, flip-inline' Where it goes when that doesn’t fit.
locale locale string the nearest lang The default filter’s language. Without a lang, the browser’s.
buttonLabel button-label string 'Show suggestions' The button’s own name, read before the label: “Show suggestions Fruit”.
optionCountAnnouncement — (property) (count: number) => string 'N options available' The announcement of the option count; '1 option available' for one.
inputId — string generated The input’s id while it has none of its own.
listId — string generated The list’s id while it has none of its own. Option ids are made from the list’s id.
labelElementType — (the label’s tag) string 'label' The tag you render as the label. Only a label gets for.
labelledBy labelled-by string — Ids of more elements that name the input and the list.
describedBy described-by string — Ids of more elements that describe the input.
virtualizer, itemSize, shouldMeasureItems, overscan, initialItemCount as on Listbox Listbox’s virtualization, passed to the list.
optionTooltip option-tooltip (the tooltip’s id) a Tooltip’s instance — One Tooltip for every option, following the highlight (see Option tooltips). On the element, only the attribute links it.

In a Form, Combobox also takes name, form, validate, validationTrigger, validationDelay, validationMessages, dependsOn, isEqual and shouldPreserve: see Field props for the form.

The list always highlights options on hover and is vertical; the popover is always non-modal, dismissable, and leaves focus in the input when it opens. Development builds warn when the input has no name: no label part, no labelledBy and no aria-label on the input.

Events

Event Callback prop Detail When
valuechange onValueChange readonly string[] The value settled to something new: an option chosen, or the text settling. Never per keystroke, never for a value you set.
inputchange onInputChange string The text changed: typing, pasting, a chosen option’s text written in, or the text going back as it settles.
openchange onOpenChange boolean The user opened or closed the list. Once per change, never for an isOpen you set.
focuschange onFocusChange boolean Focus entered or left the root. The input, the button and the popup count as one.
renderedkeyschange onRenderedKeysChange { keys: readonly string[]; sections: readonly string[] } The set of options to render changed. Only virtualized lists need it.

Actions

Action What it does
open() Opens the list, as a press on the button does, without moving focus: the selected option is highlighted, and every option shows when the text matches none. Does nothing while disabled.
close() Closes the list and leaves the text as it is.

In React and Vue they are on the handle (fruit.actions.open()); on the custom element they are methods (document.querySelector('uni-combobox').open()). While isOpen is controlled, each only reports openChange and leaves the change to you.

State

Field Type Meaning
value readonly string[] The selected key, or the custom text.
inputValue string The input’s text.
isOpen boolean The list is open.
focusedKey string | null The highlighted option, or null. Always null while closed.
isEmpty boolean No option matches the text, or there are no items.
isDisabled, isReadOnly, isRequired, isInvalid boolean The props of the same names.
isErrorShown boolean The error part is shown: invalid and not disabled.
isFocusWithin boolean Focus is in the root.
isFocusVisible boolean That focus came from the keyboard.
isVirtualized, renderedKeys, renderedSections Listbox’s virtualization state.

Select what you render, and the component renders again only when it changes:

const inputValue = useBehaviorState(fruit, (state) => state.inputValue);

On the custom element, read element.instance.getState(). See State.

Styling

Attribute On When
data-uni="combobox" root Always.
data-element every other part Always.
data-open named parts The list is open.
data-entering, data-exiting named parts The first frames after opening; closing, until your exit animations on the popover end.
data-empty named parts No option matches the text.
data-disabled, data-readonly, data-required, data-invalid named parts The combo box is disabled, read-only, required or invalid.
data-focus-within, data-focus-visible named parts Focus is in the root, and from the keyboard.
data-pressed, data-hovered button As on Button.
data-placement popover, arrow The side the popover opened on: top, bottom, left or right.
data-focused option The option is highlighted, by the keys or the mouse.
data-focus-visible option The highlight came from the keys.
data-selected, data-hovered, data-pressed, data-disabled option Listbox’s.
hidden option, group The typed text leaves it out.

“Named parts” are every part except scroller and hiddenInput. State attributes are present or absent, never "false". The input also has the native required, readonly and disabled, for :required, :read-only and :disabled.

The popover gets Popover’s custom properties, as on Select: --anchor-width (the anchor’s width, else the input’s), --available-width and --available-height. Use them on the popover:

[data-uni='combobox'] [data-element='Popover'] {
  min-width: var(--anchor-width);
  max-height: min(15rem, var(--available-height));
  margin: 4px 0; /* the gap from the field */
  overflow: auto;
}

Default styles

The optional @uniaria/styles package styles every part, as in the demo above, whose CSS tab holds only the page’s layout. The anchor is the field’s box, as TextField’s wrapper, around the input and the button, a quiet 24-pixel button that draws a chevron, so leave your icon out. The box keeps its ring while the keys move the highlight, since focus stays in the input. The popover, its motion and the options are Select’s: an option’s text takes one line and ends in an ellipsis, so pair the list with an option tooltip, and its extras sit before the check.

Keyboard

On the input. Keys with Ctrl or Meta, and every key while an input method is composing, stay with the browser.

Key Closed Open
ArrowDown Opens and highlights the selected option, else the first. Highlights the next option; from none, the first. Wraps with shouldFocusWrap.
ArrowUp Opens and highlights the selected option, else the last. Highlights the previous option; from none, the last.
Alt+ArrowDown Opens with nothing highlighted. Nothing.
Alt+ArrowUp Nothing. Closes, keeping the text.
PageDown, PageUp The browser’s. With an option highlighted, moves the highlight by the list’s visible height.
Home, End, ArrowLeft, ArrowRight The browser’s: the caret moves. The caret moves and the highlight clears.
Enter Settles the text. Not prevented, so a form submits. Chooses the highlighted option. With none, settles the text and closes.
Escape Puts back the settled text if the user changed it. Otherwise not handled, so it reaches a dialog around the combo box. Closes, keeping the text.
Tab, Shift+Tab The browser’s. The text settles as focus leaves. The same. The list closes without choosing the highlighted option.
Typing, Backspace, Delete, pasting Edits the text; with popoverTrigger 'input' or 'focus' and non-blank text, opens the list. Edits the text. The filter follows it and the highlight clears.

Accessibility

  • Focus stays in the input. The highlighted option is the input’s aria-activedescendant, written only once the option’s element exists. The list and options never take focus, and a press on an option leaves focus in the input. A highlight moved by the keys scrolls into view, which browsers don’t do for aria-activedescendant.
  • Nothing is highlighted while typing. Enter then settles the text, and ArrowDown starts highlighting. NVDA stops reading typed characters while an active descendant is set.
  • The option count is announced politely when the list opens and when typing changes it while open: “3 options available”. Matching disabled options count too. Translate it with optionCountAnnouncement.
  • Spoken output. The input reads “combobox, Fruit, has popup listbox, not expanded, autocomplete in list”, and while an option is highlighted, “active descendant Banana”. Filtered-out options aren’t read.
  • Option tooltips add nothing. With relation: 'none' the tooltip is hidden from assistive technology, and the option’s whole text is read from the option already, cut off or not.
  • The button isn’t a tab stop and never takes focus; a press on it focuses the input, which brings up the on-screen keyboard on touch screens. It reads “button, Show suggestions Fruit”.
  • The label is a <label for>, so clicking it focuses the input.
  • The popup is non-modal. The page stays exposed to assistive technology, Tab leaves the combo box as usual, and one press outside closes the list and acts on what it hit.
  • The root part is needed: focus leaving it is what settles the text.

Compared with other libraries

  • No highlight while typing, as in React Aria, Zag and the APG’s list example. Cube UI Kit highlights the first match, which makes a custom value that is part of an option’s text impossible to enter with Enter.
  • Enter with nothing highlighted settles the text and lets a form submit. The APG’s example always prevents Enter.
  • Blur selects an exact or only match, as Cube UI Kit does. React Aria reverts the text, or keeps it as a custom value.
  • A custom value is the value (value: ['Mango smoothie']), which a form submits. React Aria keeps the value empty and the text in inputValue.
  • Home and End move the caret, as the APG says. React Aria moves the highlight while the list is open.
  • Escape on a closed combo box puts back the settled text, else reaches a dialog around it. The APG clears the text.
  • Tab doesn’t choose the highlighted option. React Aria and the APG’s example choose it.
  • The page stays exposed while the list is open. React Aria hides everything but the field and the popup from assistive technology.
  • Compared with <datalist>, options can hold markup and groups, the value can differ from the text, and filtering and keys work the same in every browser.