combo-box

Autocomplete combobox with filtering, keyboard navigation, and native form association. Supports single-select and multi-select tag modes.

Overview

An autocomplete combobox following the W3C ARIA combobox pattern. Users type to filter a list of options, navigate with arrow keys, and select with Enter or click. The selected value participates in native form submission via ElementInternals.

Add data-multiple to enable multi-select tag mode, where users can select multiple items as tag chips.

Usage

Place an <input> and a <ul> inside <combo-box>. Each <li> needs a data-value attribute for the form value. The visible text content becomes the label.

<combo-box name="country"> <input type="text" placeholder="Search countries..."> <ul> <li data-value="us">United States</li> <li data-value="gb">United Kingdom</li> <li data-value="ca">Canada</li> <li data-value="au">Australia</li> </ul> </combo-box>

Inside a Form

Use the standard name attribute to set the form field name. The selected data-value is submitted as the form value. Add data-required to enable required validation.

<form id="my-form"> <combo-box name="fruit" data-required> <input type="text" placeholder="Pick a fruit..."> <ul> <li data-value="apple">Apple</li> <li data-value="banana">Banana</li> <li data-value="cherry">Cherry</li> </ul> </combo-box> <button type="submit">Submit</button> </form> <script> document.getElementById('my-form') .addEventListener('submit', (e) => { e.preventDefault(); const data = new FormData(e.target); console.log('fruit:', data.get('fruit')); }); </script>

Filter Modes

By default, typing filters options by substring match (contains). Set data-filter="startsWith" to only match from the beginning of each option.

<!-- Default: contains (substring match) --> <combo-box name="item" data-filter="contains"> ... </combo-box> <!-- Starts-with matching --> <combo-box name="item" data-filter="startsWith"> ... </combo-box>

Multi-Select Mode

Add data-multiple to switch to multi-select tag mode. Users select multiple items which appear as removable tag chips. Selected options are hidden from the dropdown to prevent duplicates.

<combo-box name="topics" data-multiple> <input type="text" placeholder="Search topics..."> <ul> <li data-value="js">JavaScript</li> <li data-value="css">CSS</li> <li data-value="html">HTML</li> </ul> </combo-box>

Multi-Select Options

In multi mode, use data-max to limit the number of selections and data-allow-custom to let users type custom entries (press Enter or comma to add). Each selected tag is submitted as a separate FormData entry (like multiple checkboxes with the same name).

<form id="my-form"> <combo-box name="skills" data-multiple data-required data-max="5" data-allow-custom> <input type="text" placeholder="Add skills..."> <ul> <li data-value="design">Design</li> <li data-value="frontend">Frontend</li> <li data-value="backend">Backend</li> </ul> </combo-box> <button type="submit">Submit</button> </form> <script> document.getElementById('my-form') .addEventListener('submit', (e) => { e.preventDefault(); const data = new FormData(e.target); // Multiple values under same name console.log('skills:', data.getAll('skills')); }); </script>

Attributes

AttributeValuesDefaultDescription
namestringForm field name (standard HTML attribute)
data-requiredbooleanRequire a selection for form validation
data-filter"contains", "startsWith""contains"How typing filters the options
data-valuestringGet/set selected value (reflected, single mode)
data-placeholderstringInput placeholder text
data-openbooleanReflected when the listbox is open
data-multiplebooleanEnable multi-select tag mode
data-maxnumberMaximum number of tags (multi mode)
data-allow-custombooleanAllow typed entries via Enter/comma (multi mode)

Events

EventDetailDescription
combo-box:change{ value, label }Fired when an option is selected (single mode).
combo-box:change{ values: string[], labels: string[] }Fired when tags are added or removed (multi mode).
combo-box:openFired when the listbox opens.
combo-box:closeFired when the listbox closes.

Single Mode

const combo = document.querySelector('combo-box'); combo.addEventListener('combo-box:change', (e) => { console.log('Selected:', e.detail.value, e.detail.label); }); combo.addEventListener('combo-box:open', () => { console.log('Listbox opened'); }); combo.addEventListener('combo-box:close', () => { console.log('Listbox closed'); });

Multi Mode

const combo = document.querySelector('combo-box[data-multiple]'); combo.addEventListener('combo-box:change', (e) => { console.log('Values:', e.detail.values); console.log('Labels:', e.detail.labels); });

JavaScript API

PropertyTypeModeDescription
element.valuestringSingleCurrent selected value (read/write)
element.labelstringSingleCurrent selected label (read-only)
element.valuesstring[]MultiArray of selected tag values (read-only)
element.labelsstring[]MultiArray of selected tag labels (read-only)

Single Mode

const el = document.querySelector('combo-box'); // Read current value and label console.log(el.value); // "us" console.log(el.label); // "United States" // Set value programmatically el.value = 'gb'; // Clear selection el.value = '';

Multi Mode

const el = document.querySelector('combo-box[data-multiple]'); // Read current values and labels console.log(el.values); // ["js", "css"] console.log(el.labels); // ["JavaScript", "CSS"]

Keyboard Navigation

KeyAction
ArrowDownOpen listbox / move to next option
ArrowUpOpen listbox / move to previous option
HomeJump to first option
EndJump to last option
EnterSelect option (single) or add tag (multi). With data-allow-custom: add typed text.
EscapeClose listbox
TabClose listbox and move focus
BackspaceRemove last tag when input is empty (multi mode)
, (comma)Add custom tag (multi mode with data-allow-custom)

Accessibility

ARIA Pattern

Implements the W3C ARIA Combobox pattern:

  • Input has role="combobox", aria-expanded, aria-autocomplete="list", and aria-controls
  • List has role="listbox" with an auto-generated ID
  • Options have role="option" and aria-selected
  • Active option announced via aria-activedescendant
  • Multi mode adds aria-multiselectable="true" and an aria-live region for tag add/remove announcements

Form Validation

Uses ElementInternals.setValidity() so screen readers can announce validation errors. Works with :user-valid and :user-invalid CSS pseudo-classes.

Form Association

This component is a Form-Associated Custom Element. It uses ElementInternals to:

  • Submit the selected data-value via FormData (single mode: one value; multi mode: one entry per tag)
  • Validate with setValidity() for required fields
  • Reset on form reset via formResetCallback()
  • Restore state from browser history via formStateRestoreCallback() (single mode)

No hidden inputs needed. The component name is set via the standard name attribute.

Styling

The component uses @scope (combo-box) for shared CSS encapsulation and @scope (combo-box[data-multiple]) for tag-specific styles. Override option states and tag chip appearance using standard selectors:

/* Option hover/active states */ combo-box li[data-value]:hover { background: var(--color-surface-alt); } combo-box li[data-active] { outline: 2px solid var(--color-interactive); } /* Selected option */ combo-box li[aria-selected="true"] { font-weight: 500; color: var(--color-interactive); } /* Multi-select tag chip styling */ combo-box[data-multiple] .tag { background: var(--color-surface-raised); border: var(--border-width-thin) solid var(--color-border); border-radius: var(--radius-pill); } /* Remove button hover */ combo-box[data-multiple] .tag button:hover { color: var(--color-error); }

Progressive Enhancement

Without JavaScript, <combo-box> renders as a plain text input above a visible, scrollable list. Users can still see all options and type into the input. Once JS loads, the component adds filtering, keyboard navigation, and form association.

/* Without JS: plain text input + visible list */ combo-box:not(:defined) { display: block; } combo-box:not(:defined) > ul { list-style: none; padding: 0; margin-block-start: var(--size-xs); border: var(--border-width-thin) solid var(--color-border); border-radius: var(--radius-m); max-block-size: 12rem; overflow-y: auto; }

Related