Combobox
An autocomplete input that combines a text field with a filterable dropdown listbox. Users can type to narrow the displayed options or use arrow keys to browse, making it ideal for long option lists.
Features
- ⌨️ Full Keyboard Nav — ArrowDown/Up, Enter, Escape, Home, End, Tab
- ⏳ Loading State —
loadingattribute shows a spinner while options are being fetched - ⚡ Virtualised Rendering — powered by
@vielzeug/virtualitfor smooth performance with large option lists - ✨ Creatable — allow users to create new options when no match is found
- ❌ Clearable — optional clear button to reset the value
- 🌈 6 Semantic Colors — primary, secondary, info, success, warning, error
- 🎨 5 Variants — solid, flat, bordered, outline, ghost
- 🏷️ Label Placement — inset (default) or outside
- 📏 3 Sizes — sm, md, lg
- 📝 Helper & Error Text — inline helper or error message below the field
- 🔍 Live Filtering — options narrow as the user types
- 🔗 Form-Associated — participates in native form submission
- 🔲 Multiselect —
multiplemode shows selected values as removable chips - 🖼️ Option Icons — each option supports a leading
iconnamed slot - 🚫 No-Filter Mode — keeps all options visible for server-side search
- 🧩 Component Options — place
<bit-combobox-option>children for rich, slot-based option content
Source Code
View Source Code
import { computed, define, effect, html, inject, onCleanup, prop, signal } from '@vielzeug/craftit';
import {
createChoiceField,
createPopupListControl,
createPressControl,
type OverlayCloseReason,
type OverlayOpenReason,
} from '@vielzeug/craftit/controls';
import type { AddEventListeners } from '../../types';
import type { BitComboboxEvents, BitComboboxProps, ComboboxOptionInput, ComboboxOptionItem } from './combobox.types';
import { disabledLoadingMixin, forcedColorsFocusMixin, formFieldMixins, sizeVariantMixin } from '../../styles';
import { FIELD_SIZE_PRESET } from '../shared/design-presets';
import { createDropdownPositioner, mountFormContextSync } from '../shared/dom-sync';
import { FORM_CTX } from '../shared/form-context';
import { createChoiceChangeDetail } from '../shared/utils';
import { filterOptions, getCreatableLabel, makeCreatableValue, parseSlottedOptions } from './combobox-options';
import '../../feedback/chip/chip';
import componentStyles from './combobox.css?inline';
export type { BitComboboxEvents, BitComboboxProps } from './combobox.types';
/**
* A searchable select field with multiple selection, custom option creation, and large-list support.
*
* @element bit-combobox
*
* @attr {string} value - Selected value(s). Use comma-separated for multiple.
* @attr {boolean} multiple - Enable multiple selection
* @attr {boolean} creatable - Allow users to create custom options from search query
* @attr {boolean} no-filter - Disable client-side filtering (useful for server-side search)
* @attr {string} placeholder - Placeholder text
*
* @fires {CustomEvent} change - Emitted when selection changes. detail: { value: string | string[], values: string[], labels: string[] }
* @fires {CustomEvent} search - Emitted when user types. detail: { query: string }
*
* @slot - Slotted combobox options and option groups
* @cssprop --border - Border token for the combobox field and dropdown
* @cssprop --color-canvas - Surface background for the combobox and its menu
* @cssprop --color-contrast-100 - Hover background for option rows and field chrome
* @cssprop --color-contrast-200 - Divider and border contrast color
* @cssprop --color-contrast-300 - Subtle contrast tone for disabled or muted UI
* @cssprop --color-contrast-400 - Secondary text color inside the field
* @cssprop --color-contrast-50 - Soft background for the dropdown and inset label
* @cssprop --color-contrast-500 - Helper and placeholder text color
* @cssprop --color-contrast-600 - Stronger text color for selected values
* @cssprop --color-contrast-900 - Deep contrast color used for focus and emphasis
* @cssprop --color-error - Error accent color for invalid states
* @cssprop --color-error-focus-shadow - Error focus ring shadow for validation states
*
* @part wrapper - Root wrapper around the entire field
* @part label - Label element shown inside or outside the field
* @part field - Field container that holds the trigger input and clear button
* @part input - Search input used to filter and select options
* @part clear-btn - Button that clears the current selection/query
* @part dropdown - Popup list container for options
* @part helper-text - Helper text displayed below the field
* @example
* ```html
* <bit-combobox label="Country" name="country">
* <bit-combobox-option value="us">United States</bit-combobox-option>
* <bit-combobox-option value="gb">United Kingdom</bit-combobox-option>
* <bit-combobox-option value="de" disabled>Germany</bit-combobox-option>
* </bit-combobox>
* ```
*/
export const COMBOBOX_TAG = define<BitComboboxProps, BitComboboxEvents>('bit-combobox', {
formAssociated: true,
props: {
color: undefined,
creatable: false,
disabled: false,
error: undefined,
fullwidth: false,
helper: undefined,
label: undefined,
'label-placement': prop.oneOf(['inset', 'outside', 'hidden'] as const, 'inset'),
loading: false,
multiple: false,
name: undefined,
'no-filter': false,
options: undefined,
placeholder: 'Select...',
rounded: undefined,
size: undefined,
value: undefined,
variant: undefined,
},
setup(props, { emit, host }) {
const formCtx = inject(FORM_CTX);
const isOpen = signal(false);
const query = signal('');
const triggerRef = { value: null as HTMLInputElement | null };
const choice = createChoiceField({
context: formCtx,
disabled: props.disabled,
error: props.error,
helper: props.helper,
multiple: props.multiple,
name: props.name,
prefix: 'combobox',
value: props.value as any,
});
const {
disabled: isDisabled,
fieldId: comboId,
helperId,
labelInsetId,
labelOutsideId,
selectedValues,
triggerValidation,
} = choice;
mountFormContextSync(host.el, formCtx, props);
// ── State ────────────────────────────────────────────────────────────────
const isMultiple = () => Boolean(props.multiple.value);
const isCreatable = () => Boolean(props.creatable.value);
const isNoFilter = () => Boolean(props['no-filter'].value);
const hasLabel = () => !!props.label.value;
const outsideLabelHidden = () => !props.label.value || props['label-placement'].value !== 'outside';
const insetLabelHidden = () => !props.label.value || props['label-placement'].value !== 'inset';
host.bind({
attr: {
open: () => (isOpen.value ? true : undefined),
},
prop: {
value: {
get: () => (isMultiple() ? selectedValues.value : selectedValue.value),
set: (val: any) => {
if (Array.isArray(val)) {
choice.setValues(val.map((entry) => String(entry ?? '')));
return;
}
if (val == null || val === '') {
choice.clear();
return;
}
choice.setValues([String(val)]);
},
},
},
});
const focusedIndex = signal(-1);
let lastQueryBeforeClear: string | null = null;
let isRestoringQuery = false;
// Convenience getter for single-select
const selectedValue = computed(() => selectedValues.value[0] ?? '');
const hasValue = () => selectedValues.value.length > 0;
let inputEl: HTMLInputElement | null = null;
let fieldEl: HTMLElement | null = null;
let dropdownEl: HTMLElement | null = null;
let listboxEl: HTMLElement | null = null;
function syncPopupElements() {
const root = host.el.shadowRoot;
fieldEl = root?.querySelector<HTMLElement>('.field') ?? null;
dropdownEl = root?.querySelector<HTMLElement>('.dropdown') ?? null;
listboxEl = root?.querySelector<HTMLElement>('[role="listbox"]') ?? null;
}
function getLiveInput(): HTMLInputElement | null {
const liveInput = host.el.shadowRoot?.querySelector<HTMLInputElement>('input[role="combobox"]') ?? null;
if (liveInput) inputEl = liveInput;
return liveInput ?? inputEl;
}
function focusLiveInput() {
getLiveInput()?.focus();
}
// ── Options ──────────────────────────────────────────────────────────────
const slottedOptions = signal<ComboboxOptionItem[]>([]);
const createdOptions = signal<ComboboxOptionItem[]>([]);
const isLoading = () => Boolean(props.loading.value);
function normalizeOption(option: ComboboxOptionInput): ComboboxOptionItem {
return {
disabled: Boolean(option.disabled),
iconEl: option.iconEl ?? null,
label: option.label ?? option.value,
value: option.value,
};
}
// Merged options: explicit prop value overrides slotted options.
const allOptions = computed<ComboboxOptionItem[]>(() => {
const optionsProp = props.options.value;
const base = Array.isArray(optionsProp) ? optionsProp.map(normalizeOption) : slottedOptions.value;
if (createdOptions.value.length === 0) return base;
return [...base, ...createdOptions.value];
});
const selectionController = {
clear: () => {
choice.clear();
},
remove: (key: string) => {
choice.removeValue(key);
},
select: (key: string) => {
choice.selectValue(key);
},
toggle: (key: string) => {
choice.toggleValue(key);
},
};
function readOptions(elements: Element[] = Array.from(host.el.children)) {
slottedOptions.value = parseSlottedOptions(elements);
if (!isMultiple()) {
const match = allOptions.value.find((option) => option.value === selectedValue.value);
query.value = match?.label ?? selectedValue.value;
}
}
// Initialize from light DOM immediately; onMounted/observer keep this in sync afterwards.
readOptions();
const filteredOptions = signal<ComboboxOptionItem[]>([]);
effect(() => {
const nextOptions = filterOptions(allOptions.value, query.value, isNoFilter());
filteredOptions.value = isMultiple()
? nextOptions.filter((option) => !selectedValues.value.includes(option.value))
: nextOptions;
});
// "Create" option shown when creatable + query doesn't match any existing option
const creatableLabel = computed(() => {
return getCreatableLabel(query.value, isCreatable(), filteredOptions.value);
});
const assistiveText = choice.assistive;
const inputPlaceholder = () =>
isMultiple() && selectedValues.value.length > 0 ? '' : props.placeholder.value || '';
const selectedValueItems = computed(() => selectedValues.value);
const selectedLabelItems = computed(() =>
selectedValues.value.map((value) => allOptions.value.find((option) => option.value === value)?.label ?? value),
);
function emitChange(originalEvent?: Event) {
emit('change', createChoiceChangeDetail(selectedValueItems.value, selectedLabelItems.value, originalEvent));
}
function removeChip(event: Event): void {
event.stopPropagation();
const value = (event as CustomEvent<{ value?: string }>).detail?.value;
if (value === undefined) return;
selectionController.remove(value);
emitChange(event);
triggerValidation('change');
}
// ── Positioning (shared positioner) ──────────────────────────────────────
const positioner = createDropdownPositioner(
() => {
syncPopupElements();
return fieldEl;
},
() => {
syncPopupElements();
return dropdownEl;
},
);
const popupList = createPopupListControl({
ariaSync: {
additional: {
autocomplete: 'list',
describedby: () => (props.error.value || props.helper.value ? helperId : null),
invalid: () => !!props.error.value,
labelledby: () => (hasLabel() ? `${labelOutsideId} ${labelInsetId}` : null),
},
role: 'listbox',
},
getBoundaryElement: () => host.el,
getIndex: () => focusedIndex.value,
getItems: () => filteredOptions.value,
getPanelElement: () => dropdownEl ?? host.el.shadowRoot?.querySelector<HTMLElement>('.dropdown') ?? null,
getTriggerElement: () => getLiveInput(),
isDisabled: () => isDisabled.value,
isItemDisabled: (option) => option.disabled,
isOpen: () => isOpen.value,
listId: `${comboId}-listbox`,
onClose: (reason) => {
emit('close', { reason });
restoreQueryFromSelection();
triggerValidation('blur');
},
onOpen: (reason) => emit('open', { reason }),
positioner: {
floating: () => dropdownEl,
reference: () => fieldEl,
update: () => positioner.updatePosition(),
},
restoreFocus: false,
setIndex: (index: number) => {
focusedIndex.value = index;
scrollFocusedIntoView();
},
setOpen: (next) => {
isOpen.value = next;
},
triggerRef,
});
function syncRenderedOptionState(): void {
syncPopupElements();
if (!listboxEl) return;
for (const optionEl of Array.from(listboxEl.querySelectorAll<HTMLElement>('.option'))) {
const option = resolveOptionFromElement(optionEl);
if (!option) continue;
const isSelected = isMultiple()
? selectedValues.value.includes(option.value)
: selectedValue.value === option.value;
const isFocused = filteredOptions.value[focusedIndex.value]?.value === option.value;
optionEl.toggleAttribute('data-selected', isSelected);
optionEl.setAttribute('aria-selected', String(isSelected));
optionEl.toggleAttribute('data-focused', isFocused);
}
}
function restoreQueryFromSelection() {
// Keep input text and selected value in sync whenever the popup closes.
if (!isMultiple()) {
const match = allOptions.value.find((option) => option.value === selectedValue.value);
isRestoringQuery = true;
query.value = match?.label ?? '';
Promise.resolve().then(() => {
isRestoringQuery = false;
});
return;
}
query.value = '';
}
effect(() => {
if (isOpen.value && !isMultiple() && selectedValue.value && focusedIndex.value === -1 && query.value === '') {
const selectedIndex = filteredOptions.value.findIndex((option) => option.value === selectedValue.value);
if (selectedIndex >= 0) {
focusedIndex.value = selectedIndex;
}
}
});
// ── Open / Close ─────────────────────────────────────────────────────────
function openPopup(clearFilter = true, reason: OverlayOpenReason = 'programmatic') {
if (clearFilter) {
lastQueryBeforeClear = query.value;
query.value = '';
}
popupList.open(reason);
if (!isMultiple() && selectedValue.value) {
const freshOptions = filterOptions(allOptions.value, '', isNoFilter());
const selectedIndex = freshOptions.findIndex((option) => option.value === selectedValue.value);
if (selectedIndex >= 0) {
focusedIndex.value = selectedIndex;
requestAnimationFrame(() => {
focusedIndex.value = selectedIndex;
syncRenderedOptionState();
scrollFocusedIntoView();
});
}
}
}
function closePopup(reason: OverlayCloseReason = 'programmatic') {
popupList.close(reason);
}
const fieldPress = createPressControl({
disabled: () => isDisabled.value,
onPress: () => {
if (!isOpen.value) openPopup(true, 'trigger');
focusLiveInput();
},
});
const enterPress = createPressControl({
disabled: () => isDisabled.value,
keys: ['Enter'],
onPress: (originalEvent: any) => {
const opts = filteredOptions.value;
if (isOpen.value && focusedIndex.value >= 0 && focusedIndex.value < opts.length) {
selectOption(opts[focusedIndex.value], originalEvent);
} else if (isOpen.value && focusedIndex.value === -1 && creatableLabel.value) {
// Focused on the "create" item
createOption(creatableLabel.value, originalEvent);
} else if (!isOpen.value) {
openPopup(true, 'trigger');
}
},
});
// ── Selection ────────────────────────────────────────────────────────────
function selectOption(opt: ComboboxOptionItem, originalEvent?: Event) {
if (opt.disabled) return;
if (isMultiple()) {
selectionController.toggle(opt.value);
query.value = '';
emitChange(originalEvent);
triggerValidation('change');
// Keep dropdown open in multiple mode
focusLiveInput();
requestAnimationFrame(() => focusLiveInput());
} else {
selectionController.select(opt.value);
query.value = opt.label;
emitChange(originalEvent);
triggerValidation('change');
closePopup();
focusLiveInput();
}
}
function resolveOptionFromElement(optionEl: HTMLElement): ComboboxOptionItem | null {
const indexAttr = optionEl.getAttribute('data-option-index');
const index = indexAttr ? Number(indexAttr) : -1;
if (Number.isInteger(index) && index >= 0 && index < filteredOptions.value.length) {
return filteredOptions.value[index] ?? null;
}
const valueAttr = optionEl.getAttribute('data-option-value');
if (valueAttr) {
const byValue = filteredOptions.value.find((option) => option.value === valueAttr);
if (byValue) return byValue;
}
const labelText = optionEl.querySelector('span')?.textContent?.trim() ?? optionEl.textContent?.trim() ?? '';
if (!labelText) return null;
return filteredOptions.value.find((option) => option.label === labelText || option.value === labelText) ?? null;
}
function updateImmediateOptionSelection(optionEl: HTMLElement, option: ComboboxOptionItem): void {
if (isMultiple()) {
const nextSelected = !selectedValues.value.includes(option.value);
optionEl.toggleAttribute('data-selected', nextSelected);
optionEl.setAttribute('aria-selected', String(nextSelected));
return;
}
const optionElements = optionEl
.closest<HTMLElement>('[role="listbox"]')
?.querySelectorAll<HTMLElement>('.option');
for (const candidate of optionElements ?? []) {
const isSelected = candidate === optionEl;
candidate.toggleAttribute('data-selected', isSelected);
candidate.setAttribute('aria-selected', String(isSelected));
}
}
function clearValue(e: Event) {
e.stopPropagation();
selectionController.clear();
query.value = '';
emitChange(e);
triggerValidation('change');
focusLiveInput();
}
function handleInput(e: any) {
const target = e.target as HTMLInputElement;
const newValue = target.value;
// Skip all input processing if we're in the middle of restoring the query
// This prevents the clearing logic from firing during close/restore
if (isRestoringQuery) {
return;
}
if (newValue === query.value) return;
query.value = newValue;
if (!isMultiple()) {
const currentItem = selectedValues.value[0];
const currentLabel = currentItem
? (allOptions.value.find((o) => o.value === currentItem)?.label ?? currentItem)
: '';
// Preserve the current selection while typing. Selection should only
// change when a new option is committed or when the user explicitly clears.
const isJustOpening = newValue === '' && lastQueryBeforeClear === currentLabel;
if (isJustOpening) {
lastQueryBeforeClear = null;
}
}
popupList.first();
if (!isOpen.value) openPopup(false, 'trigger');
emit('search', { query: target.value });
}
function handleFocus() {
if (!isOpen.value) openPopup(true, 'trigger');
}
// ── Keyboard Navigation ──────────────────────────────────────────────────
function handleKeydown(e: KeyboardEvent) {
if (isDisabled.value) return;
if (popupList.handleListKeydown(e)) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen.value) {
openPopup(true, 'trigger');
popupList.first();
} else {
popupList.next();
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen.value) {
openPopup(true, 'trigger');
} else {
popupList.prev();
}
break;
case 'Backspace':
// In multiple mode, remove the last chip when the input is empty
if (isMultiple() && !query.value && selectedValues.value.length > 0) {
choice.removeValue(selectedValues.value[selectedValues.value.length - 1] ?? '');
emitChange(e);
triggerValidation('change');
}
break;
case 'Enter':
enterPress.handleKeydown(e);
break;
case 'Escape':
e.preventDefault();
if (isOpen.value) {
closePopup('escape');
}
break;
case 'Tab':
closePopup('programmatic');
break;
default:
break;
}
}
function scrollFocusedIntoView() {
syncPopupElements();
if (!listboxEl) return;
const focusedEl = listboxEl.querySelector<HTMLElement>('[data-focused]');
focusedEl?.scrollIntoView({ block: 'nearest' });
}
// ── Create option ────────────────────────────────────────────────────────
function createOption(label: string, originalEvent?: Event) {
const actualLabel = label.startsWith('Create "') && label.endsWith('"') ? label.slice(8, -1) : label;
const value = makeCreatableValue(actualLabel);
const newOpt: ComboboxOptionItem = { disabled: false, iconEl: null, label: actualLabel, value };
createdOptions.value = [...createdOptions.value, newOpt];
selectOption(newOpt, originalEvent);
}
// ── Lifecycle ────────────────────────────────────────────────────────────
const observeLightDomOptions = (): (() => void) => {
const observer = new MutationObserver(() => {
readOptions();
});
observer.observe(host.el, {
attributeFilter: ['disabled', 'label', 'value'],
attributes: true,
childList: true,
subtree: true,
});
return () => observer.disconnect();
};
const stopObserving = observeLightDomOptions();
const handleShadowOptionPointerMove = (event: PointerEvent): void => {
const target = event.target;
if (!(target instanceof Element)) return;
const optionEl = target.closest<HTMLElement>('.option');
if (!optionEl) return;
const option = resolveOptionFromElement(optionEl);
if (!option || option.disabled) return;
const focusedIdx = filteredOptions.value.findIndex((candidate) => candidate.value === option.value);
if (focusedIdx >= 0) {
focusedIndex.value = focusedIdx;
}
};
const handleShadowOptionClick = (event: MouseEvent): void => {
const target = event.target;
if (!(target instanceof Element)) return;
const createRow = target.closest<HTMLElement>('.no-results-create');
if (createRow) {
event.preventDefault();
event.stopPropagation();
createOption(creatableLabel.value, event);
return;
}
const optionEl = target.closest<HTMLElement>('.option');
if (!optionEl) return;
const option = resolveOptionFromElement(optionEl);
if (!option || option.disabled) return;
event.preventDefault();
event.stopPropagation();
updateImmediateOptionSelection(optionEl, option);
selectOption(option, event);
};
const shadowRoot = host.el.shadowRoot;
if (shadowRoot) {
shadowRoot.addEventListener('pointermove', handleShadowOptionPointerMove as EventListener);
shadowRoot.addEventListener('click', handleShadowOptionClick as EventListener);
}
const handleDocumentCaptureClick = (event: Event): void => {
if (!isOpen.value) return;
const path = (event as Event & { composedPath?: () => EventTarget[] }).composedPath?.() ?? [];
const nodeTarget = event.target instanceof Node ? event.target : null;
const elementTarget = event.target instanceof Element ? event.target : null;
const insideHost =
path.includes(host.el) ||
(nodeTarget
? host.el.contains(nodeTarget) || (host.el.shadowRoot ? host.el.shadowRoot.contains(nodeTarget) : false)
: false);
if (!insideHost) return;
const createRowFromPath = path.find(
(entry): entry is HTMLElement => entry instanceof HTMLElement && entry.classList.contains('no-results-create'),
);
const createRow = createRowFromPath ?? elementTarget?.closest<HTMLElement>('.no-results-create') ?? null;
if (createRow) {
event.preventDefault();
event.stopImmediatePropagation();
createOption(creatableLabel.value, event);
return;
}
const optionFromPath = path.find(
(entry): entry is HTMLElement => entry instanceof HTMLElement && entry.classList.contains('option'),
);
const optionEl = optionFromPath ?? elementTarget?.closest<HTMLElement>('.option') ?? null;
if (!optionEl) return;
const option = resolveOptionFromElement(optionEl);
if (!option || option.disabled) return;
event.preventDefault();
event.stopImmediatePropagation();
updateImmediateOptionSelection(optionEl, option);
selectOption(option, event);
};
document.addEventListener('click', handleDocumentCaptureClick, { capture: true });
const createListboxListeners = (listEl: HTMLElement): (() => void) => {
const handleActivate = (event: Event) => {
const target = event.target;
if (!(target instanceof Element)) return;
const createRow = target.closest<HTMLElement>('.no-results-create');
if (createRow) {
event.preventDefault();
event.stopPropagation();
createOption(creatableLabel.value, event);
return;
}
const optionEl = target.closest<HTMLElement>('.option');
if (!optionEl) return;
event.preventDefault();
event.stopPropagation();
const option = resolveOptionFromElement(optionEl);
if (!option) return;
updateImmediateOptionSelection(optionEl, option);
selectOption(option, event);
};
const handlePointerMove = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const optionEl = target.closest<HTMLElement>('.option');
if (!optionEl) return;
const option = resolveOptionFromElement(optionEl);
if (!option) return;
const focusedIdx = filteredOptions.value.findIndex((candidate) => candidate.value === option.value);
if (focusedIdx >= 0) {
focusedIndex.value = focusedIdx;
}
};
listEl.addEventListener('click', handleActivate);
listEl.addEventListener('pointermove', handlePointerMove);
return () => {
listEl.removeEventListener('click', handleActivate);
listEl.removeEventListener('pointermove', handlePointerMove);
};
};
let stopListboxListeners: (() => void) | null = null;
let listboxListenersTarget: HTMLElement | null = null;
const setListboxElement = (el: HTMLElement | null): void => {
listboxEl = el;
if (listboxListenersTarget === el) return;
stopListboxListeners?.();
stopListboxListeners = null;
listboxListenersTarget = null;
if (!el) return;
stopListboxListeners = createListboxListeners(el);
listboxListenersTarget = el;
};
const ensureListboxListeners = (): void => {
syncPopupElements();
if (!listboxEl) return;
if (listboxListenersTarget === listboxEl && stopListboxListeners) return;
stopListboxListeners?.();
stopListboxListeners = createListboxListeners(listboxEl);
listboxListenersTarget = listboxEl;
};
effect(() => {
ensureListboxListeners();
if (isOpen.value) {
syncRenderedOptionState();
}
if (isOpen.value) positioner.updatePosition();
});
onCleanup(() => {
shadowRoot?.removeEventListener('pointermove', handleShadowOptionPointerMove as EventListener);
shadowRoot?.removeEventListener('click', handleShadowOptionClick as EventListener);
document.removeEventListener('click', handleDocumentCaptureClick, { capture: true });
stopListboxListeners?.();
stopListboxListeners = null;
listboxListenersTarget = null;
stopObserving();
});
return () => html`
<slot></slot>
<div class="combobox-wrapper" part="wrapper">
<label class="label-outside" for="${comboId}" id="${labelOutsideId}" ?hidden=${outsideLabelHidden} part="label">
${props.label}
</label>
<div
class="field"
part="field"
@click="${(e: MouseEvent) => {
fieldPress.handleClick(e);
}}">
<label class="label-inset" for="${comboId}" id="${labelInsetId}" ?hidden=${insetLabelHidden} part="label">
${props.label}
</label>
<div class="field-row">
<div class="chips-row">
<!-- Keep chip list diffing isolated so input node identity stays stable. -->
<span class="chips-list">
${() =>
(isMultiple() ? selectedValues.value : []).map(
(value) => html`
<bit-chip
value=${value}
label=${allOptions.value.find((option) => option.value === value)?.label ?? value}
mode="removable"
variant="flat"
size="sm"
color="${props.color}"
@remove=${removeChip}>
${allOptions.value.find((option) => option.value === value)?.label ?? value}
</bit-chip>
`,
)}
</span>
<input
ref=${(el: HTMLInputElement | null) => {
inputEl = el;
triggerRef.value = el;
if (!el) {
fieldEl = null;
return;
}
fieldEl = el.closest('.field') as HTMLElement | null;
}}
class="input"
part="input"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
id="${comboId}"
name="${props.name}"
placeholder="${inputPlaceholder}"
:aria-controls="${() => `${comboId}-listbox`}"
:aria-expanded="${() => String(isOpen.value)}"
:disabled="${isDisabled}"
@input=${handleInput}
@keydown=${handleKeydown}
@focus=${handleFocus}
:value=${query} />
</div>
<button
class="clear-btn"
part="clear-btn"
type="button"
aria-label="Clear"
tabindex="-1"
?hidden=${() => !hasValue()}
@click="${clearValue}">
<bit-icon name="x" size="12" stroke-width="2.5" aria-hidden="true"></bit-icon>
</button>
<span class="chevron" aria-hidden="true">
<bit-icon name="chevron-down" size="14" stroke-width="2" aria-hidden="true"></bit-icon>
<span class="loader" aria-label="Loading"></span>
</span>
</div>
</div>
<div
class="dropdown"
part="dropdown"
id="${() => `${comboId}-dropdown`}"
?data-open=${() => isOpen.value}
ref=${(el: HTMLElement | null) => {
dropdownEl = el;
}}>
<div
role="listbox"
id="${() => `${comboId}-listbox`}"
:style="${() =>
isOpen.value && filteredOptions.value.length > 0 ? `height:${filteredOptions.value.length * 36}px;` : ''}"
aria-label="${() => props.label.value || props.placeholder.value || 'Options'}"
ref=${(el: HTMLElement | null) => {
setListboxElement(el);
}}>
${() => {
if (!isOpen.value) return '';
if (isLoading()) {
return html`<div class="dropdown-loading">Loading...</div>`;
}
if (filteredOptions.value.length === 0) {
if (creatableLabel.value) {
return html`<button
type="button"
class="no-results-create"
?data-focused=${() => focusedIndex.value === -1}>
${creatableLabel.value}
</button>`;
}
return html`<div class="no-results" role="presentation">No results found</div>`;
}
return filteredOptions.value.map((option, index) => {
return html`<div
class="option"
role="option"
id="${comboId}-opt-${index}"
data-option-index="${index}"
data-option-value="${option.value}"
:aria-selected="${() =>
String(
isMultiple() ? selectedValues.value.includes(option.value) : selectedValue.value === option.value,
)}"
aria-disabled="${String(option.disabled)}"
style="position:absolute;top:0;left:0;right:0;transform:translateY(${index * 36}px);"
?data-focused=${() => focusedIndex.value === index}
?data-selected=${() =>
isMultiple() ? selectedValues.value.includes(option.value) : selectedValue.value === option.value}
?data-disabled=${option.disabled}>
<span>${option.label}</span>
<span class="option-check" aria-hidden="true"
><bit-icon name="check" size="14" stroke-width="2.5" aria-hidden="true"></bit-icon
></span>
</div>`;
});
}}
</div>
</div>
<span
class="helper-text"
id="${helperId}"
part="helper-text"
aria-live="polite"
?hidden=${() => !assistiveText.value.errorText && !assistiveText.value.helperText}
style="${() => (assistiveText.value.errorText ? 'color: var(--color-error);' : '')}"
>${() => assistiveText.value.errorText || assistiveText.value.helperText}</span
>
</div>
`;
},
shadow: { delegatesFocus: true },
styles: [
sizeVariantMixin(FIELD_SIZE_PRESET),
...formFieldMixins,
disabledLoadingMixin(),
forcedColorsFocusMixin('.input'),
componentStyles,
],
}) as unknown as AddEventListeners<BitComboboxEvents>;Basic Usage
Place <bit-combobox-option> elements inside <bit-combobox>. The value attribute is what gets submitted; the text content is the label used for display and filtering.
Variants
Five visual variants for different UI contexts and levels of emphasis.
Options with Icons
Add an icon named slot inside any <bit-combobox-option> for a leading icon. The icon is rendered in the dropdown alongside the label.
Colors
Sizes
Label Placement
The label can be placed inset (inside the field, above the input — default) or outside (above the field border).
Clearable
Add clearable to show a clear (×) button whenever a value is selected.
Multiselect
Add multiple to allow selecting more than one option. Each selected value is shown as a removable bit-chip inside the field. Pressing Backspace on an empty input removes the last chip.
In multiple mode the change event detail includes both value (comma-separated) and values (array):
document.querySelector('bit-combobox').addEventListener('change', (e) => {
console.log('csv:', e.detail.value); // "ts,rust,go"
console.log('array:', e.detail.values); // ["ts", "rust", "go"]
});Helper & Error Text
Disabled Options
Add the disabled attribute on a <bit-combobox-option> to prevent selection of individual options.
Disabled State
No-Filter Mode (Server-Side Search)
Set no-filter to keep all options visible regardless of what the user types. Use this when filtering happens server-side — replace the <bit-combobox-option> children based on the search event.
For a real server-side integration, replace options dynamically on search:
const cb = document.getElementById('user-cb');
cb.addEventListener('search', async (e) => {
const results = await fetch(`/api/users?q=${encodeURIComponent(e.detail.query)}`).then((r) => r.json());
cb.querySelectorAll('bit-combobox-option').forEach((el) => el.remove());
for (const user of results) {
const opt = document.createElement('bit-combobox-option');
opt.setAttribute('value', user.id);
opt.textContent = user.name;
cb.appendChild(opt);
}
});Creatable Options
Add creatable to allow users to create a new option when their query does not match any existing option. A Create “X” button appears at the bottom of the dropdown. Selecting it adds the new option and emits a bit-change event like any normal selection.
document.querySelector('bit-combobox').addEventListener('change', (e) => {
// Both selected and newly created options fire change
console.log('Selected/created:', e.detail.value, e.detail.labels);
});Loading State
Set loading to show a loading indicator inside the dropdown while options are being fetched. Use this together with no-filter for server-side search.
const cb = document.querySelector('bit-combobox');
cb.addEventListener('search', async (e) => {
cb.loading = true;
const results = await fetch(`/api/items?q=${encodeURIComponent(e.detail.query)}`).then((r) => r.json());
cb.options = results.map((r) => ({ value: r.id, label: r.name }));
cb.loading = false;
});JavaScript Options
Set the options property directly in JavaScript to provide options without using <bit-combobox-option> children. Each item only needs a value; label falls back to the same string when omitted, while disabled remains optional.
const cb = document.querySelector('bit-combobox');
cb.options = [{ value: 'ts' }, { value: 'rust', label: 'Rust' }, { value: 'go', label: 'Go', disabled: true }];Assigning a new array to options updates the dropdown immediately. When both <bit-combobox-option> children and options are present, the JS property takes precedence.
In a Form
bit-combobox is form-associated — its name attribute participates in FormData submissions.
document.getElementById('myForm').addEventListener('submit', (e) => {
e.preventDefault();
const data = new FormData(e.target);
console.log('country:', data.get('country'));
});Listening to Events
const cb = document.getElementById('my-cb');
// Fired when a value is selected from the list
cb.addEventListener('change', (e) => {
console.log('value:', e.detail.value, 'labels:', e.detail.labels);
// In multiple mode, e.detail.values is a string array
console.log('values:', e.detail.values);
});
// Fired on every keystroke in the input
cb.addEventListener('search', (e) => {
console.log('query:', e.detail.query);
});
// Fired when the popup opens/closes
cb.addEventListener('open', (e) => {
console.log('opened because:', e.detail.reason); // 'trigger' | 'programmatic'
});
cb.addEventListener('close', (e) => {
console.log('closed because:', e.detail.reason); // 'escape' | 'outside-click' | 'programmatic'
});API Reference
bit-combobox Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | '' | Currently selected value |
name | string | '' | Form field name |
label | string | '' | Label text |
label-placement | 'inset' | 'outside' | 'inset' | Label positioning |
placeholder | string | '' | Input placeholder text |
helper | string | '' | Helper text shown below the field |
error | string | '' | Error message; overrides helper text |
color | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error' | — | Color theme |
variant | 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'solid' | Visual style variant |
size | 'sm' | 'md' | 'lg' | 'md' | Field size |
rounded | 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full' | — | Border radius override |
clearable | boolean | false | Show a clear button when a value is selected |
no-filter | boolean | false | Disable client-side option filtering (for server-side use) |
creatable | boolean | false | Show a "Create X" option when no match is found |
loading | boolean | false | Show a loading indicator in the dropdown |
multiple | boolean | false | Allow selecting multiple options (chips are shown in field) |
fullwidth | boolean | false | Expand to fill the container width |
disabled | boolean | false | Disable the control |
bit-combobox Slots
| Slot | Description |
|---|---|
| (default) | bit-combobox-option elements defining the option list |
bit-combobox-option Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | '' | The value submitted to the form and emitted in bit-change |
label | string | — | Explicit text used for display and filtering; falls back to the element's textContent |
disabled | boolean | false | Prevent this option from being selected |
bit-combobox-option Slots
| Slot | Description |
|---|---|
icon | Optional leading icon or decoration |
| (default) | Label text for the option |
Events
| Event | Detail | Description |
|---|---|---|
change | { value: string, values: string[], labels: string[], originalEvent?: Event } | Emitted when selected value(s) change |
open | { reason: 'trigger' | 'programmatic' } | Emitted when the dropdown opens |
close | { reason: 'escape' | 'outside-click' | 'programmatic' } | Emitted when the dropdown closes |
search | { query: string } | Emitted on every keystroke in the text input |
CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--combobox-font-size | Input / option font size | --text-sm |
--combobox-gap | Gap between field elements | --size-2 |
--combobox-padding | Field padding | Inner paddings |
--combobox-radius | Field border radius | --rounded-lg |
--combobox-bg | Field background color | --color-contrast-100 |
--combobox-border-color | Default border color | --color-contrast-300 |
Accessibility
The combobox component follows WCAG 2.1 Level AA standards.
bit-combobox
✅ Keyboard Navigation
Tabfocuses the input;ArrowDown/ArrowUpnavigate the option list.Enterconfirms selection;Escapecloses the dropdown.
✅ Screen Readers
- Uses
role="combobox"witharia-expanded,aria-controls,aria-activedescendant, andaria-autocomplete="list". - The dropdown uses
role="listbox"; each option usesrole="option"witharia-selectedandaria-disabled. aria-live="polite"on the helper / error region announces validation messages.aria-labelledbylinks the label;aria-describedbylinks helper and error text.aria-disabledreflects the disabled state.
Best Practices
Do:
- Use
placeholderto hint at the expected input (e.g. "Search countries…"). - Use
clearablewhen the field is optional and users might want to reset their choice. - Use
no-filter+bit-inputfor server-side search with large datasets. - Pair with
errortext andcolor="error"for form validation feedback.
Don't:
- Use combobox for short lists (< 6 items) — a plain
bit-selectis simpler. - Rely on the dropdown alone: always provide a visible label so the purpose is clear when the list is hidden.