Skip to content

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 Stateloading attribute shows a spinner while options are being fetched
  • Virtualised Rendering — powered by @vielzeug/virtualit for 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
  • 🔲 Multiselectmultiple mode shows selected values as removable chips
  • 🖼️ Option Icons — each option supports a leading icon named 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
ts
import {
  aria,
  computed,
  createFormIds,
  css,
  defineComponent,
  defineField,
  effect,
  html,
  inject,
  onMount,
  onSlotChange,
  ref,
  signal,
  typed,
  watch,
} from '@vielzeug/craftit';
import {
  createListNavigation,
  createOverlayControl,
  createSelectionControl,
  type ListNavigationResult,
  type OverlayOpenReason,
} from '@vielzeug/craftit/labs';

import type { AddEventListeners } from '../../types';

import '../../feedback/chip/chip';
import { checkIconHTML, chevronDownIcon, clearIcon } from '../../icons';
import { disabledLoadingMixin, forcedColorsFocusMixin, formFieldMixins, sizeVariantMixin } from '../../styles';
import { FIELD_SIZE_PRESET } from '../shared/design-presets';
import { createDropdownPositioner, mountLabelSyncStandalone } from '../shared/dom-sync';
import { FORM_CTX } from '../shared/form-context';
import { computeControlledCsvState, createChoiceChangeDetail, resolveMergedAssistiveText } from '../shared/utils';
import { createFieldValidation } from '../shared/validation';
import {
  backfillSelectionLabels,
  filterOptions,
  getCreatableLabel,
  makeCreatableValue,
  parseSlottedOptions,
} from './combobox-options';
import { createComboboxVirtualizer } from './combobox-virtualizer';
import componentStyles from './combobox.css?inline';
import {
  type BitComboboxEvents,
  type BitComboboxProps,
  type ComboboxOptionItem,
  type ComboboxSelectionItem,
} from './combobox.types';

export type { BitComboboxEvents, BitComboboxOptionProps, BitComboboxProps } from './combobox.types';

// ============================================
// Styles
// ============================================

// ============================================
// ComboboxOption Component
// ============================================

/**
 * `bit-combobox-option` — A child element of `<bit-combobox>` that represents one option.
 *
 * @slot         - Label text for the option.
 * @slot icon    - Optional leading icon or decoration.
 */
export const COMBOBOX_OPTION_TAG = defineComponent({
  setup() {
    const optionStyles = /* css */ css`
      @layer buildit.base {
        :host {
          display: none;
        }
      }
    `;

    return html`<style>
      ${optionStyles}
    </style>`;
  },
  tag: 'bit-combobox-option',
});

// ============================================
// Component
// ============================================

/**
 * `bit-combobox` — Autocomplete/combobox text input with a filterable listbox.
 *
 * Place `<bit-combobox-option>` elements as children to define the available options.
 * Each option supports a `label` attribute (falls back to text content) and an `icon` named slot.
 *
 * @example
 * ```html
 * <bit-combobox label="Country" placeholder="Search\u2026">
 *   <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 = defineComponent<BitComboboxProps, BitComboboxEvents>({
  formAssociated: true,
  props: {
    clearable: { default: false },
    color: { default: undefined },
    creatable: { default: false },
    disabled: { default: false },
    error: { default: '', omit: true },
    fullwidth: { default: false },
    helper: { default: '' },
    label: { default: '' },
    'label-placement': { default: 'inset' },
    loading: { default: false },
    multiple: { default: false },
    name: { default: '' },
    'no-filter': { default: false },
    options: typed<ComboboxOptionItem[] | undefined>(undefined, { reflect: false }),
    placeholder: { default: '' },
    rounded: { default: undefined },
    size: { default: undefined },
    value: { default: '' },
    variant: { default: undefined },
  },
  setup({ emit, host, props }) {
    const { fieldId: comboId, helperId, labelId } = createFormIds('combobox', props.name.value);
    // Label refs
    const labelOutsideRef = ref<HTMLLabelElement>();
    const labelInsetRef = ref<HTMLLabelElement>();
    const formCtx = inject(FORM_CTX, undefined);
    // Signal for the form value
    const formValue = signal(String(props.value.value ?? ''));
    const fd = defineField(
      { disabled: computed(() => Boolean(props.disabled.value) || Boolean(formCtx?.disabled.value)), value: formValue },
      {
        onReset: () => {
          formValue.value = '';
          selectedValues.value = [];
          query.value = '';
        },
      },
    );

    const { triggerValidation } = createFieldValidation(formCtx, fd);

    // ── State ────────────────────────────────────────────────────────────────
    const isOpen = signal(false);
    const query = signal('');
    const isDisabled = computed(() => Boolean(props.disabled.value));
    const isMultiple = computed(() => Boolean(props.multiple.value));
    const isCreatable = computed(() => Boolean(props.creatable.value));
    const isNoFilter = computed(() => Boolean(props['no-filter'].value));

    watch(
      isOpen,
      (value) => {
        host.toggleAttribute('open', ((value) => Boolean(value))(value));
      },
      { immediate: true },
    );

    // Multi-value state: always an array; single mode uses at most one entry
    const selectedValues = signal<ComboboxSelectionItem[]>(
      props.value.value ? [{ label: '', value: props.value.value }] : [],
    );
    const focusedIndex = signal(-1);
    const selectionController = createSelectionControl<ComboboxSelectionItem>({
      findByKey: (value) => {
        const existing = selectedValues.value.find((item) => item.value === value);

        if (existing) return existing;

        // If not found in selection, try to find in all options to get label
        const option = allOptions.value.find((o) => o.value === value);

        if (option) return { label: option.label, value: option.value };

        // Fallback: key is the value
        return { label: '', value };
      },
      getMode: () => (isMultiple.value ? 'multiple' : 'single'),
      getSelected: () => selectedValues.value,
      keyExtractor: (item) => item.value,
      setSelected: (next) => {
        selectedValues.value = next;
      },
    });

    // Sync external value prop changes to selectedValues (controlled mode)
    const syncControlledValue = (nextValue: unknown): void => {
      const state = computeControlledCsvState(String(nextValue ?? ''));

      if (state.isEmpty) {
        selectionController.clear();
        query.value = '';
        formValue.value = '';

        return;
      }

      if (isMultiple.value) {
        selectedValues.value = state.values.map((value) => ({ label: '', value }));
        formValue.value = state.formValue;

        return;
      }

      // Single mode: one value
      selectedValues.value = [{ label: '', value: state.firstValue }];
      formValue.value = state.firstValue;
    };

    watch(props.value, (newValue) => syncControlledValue(newValue), { immediate: true });
    watch(props.multiple, () => syncControlledValue(props.value.value));

    // Convenience getter for single-select
    const selectedValue = computed(() => selectedValues.value[0]?.value ?? '');
    const hasValue = computed(() => selectedValues.value.length > 0);
    const hasLabel = computed(() => !!props.label.value);
    let inputEl: HTMLInputElement | null = null;
    let fieldEl: HTMLElement | null = null;
    let dropdownEl: HTMLElement | null = null;
    let listboxEl: HTMLElement | null = null;

    function getLiveInput(): HTMLInputElement | null {
      const liveInput = host.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 = computed(() => Boolean(props.loading.value));
    // Merged options: explicit prop value overrides slotted options.
    const allOptions = computed<ComboboxOptionItem[]>(() => {
      const base = props.options.value ?? slottedOptions.value;

      if (createdOptions.value.length === 0) return base;

      return [...base, ...createdOptions.value];
    });

    function readOptions(elements: Element[] = Array.from(host.children)) {
      slottedOptions.value = parseSlottedOptions(elements);

      // Backfill labels for any already-selected values that were set before options loaded
      if (selectedValues.value.length > 0) {
        selectedValues.value = backfillSelectionLabels(selectedValues.value, allOptions.value);

        // Also sync the query in single mode
        if (!isMultiple.value && selectedValues.value.length === 1) {
          query.value = selectedValues.value[0]?.label ?? '';
        }
      }
    }

    const filteredOptions = computed<ComboboxOptionItem[]>(() => {
      return filterOptions(allOptions.value, query.value, isNoFilter.value);
    });
    // "Create" option shown when creatable + query doesn't match any existing option
    const creatableLabel = computed(() => {
      return getCreatableLabel(query.value, isCreatable.value, filteredOptions.value);
    });
    const assistiveText = computed(() => resolveMergedAssistiveText(props.error.value, props.helper.value));
    const inputPlaceholder = computed(() =>
      isMultiple.value && selectedValues.value.length > 0 ? '' : props.placeholder.value || '',
    );

    const selectedValueItems = computed(() => selectedValues.value.map((s) => s.value));
    const selectedLabelItems = computed(() =>
      selectedValues.value.map((selection) => {
        if (selection.label) return selection.label;

        return allOptions.value.find((option) => option.value === selection.value)?.label ?? selection.value;
      }),
    );

    function syncMultipleFormValue() {
      formValue.value = selectionController.serialize(',');
    }

    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);
      syncMultipleFormValue();
      emitChange(event);
      triggerValidation('change');
    }

    // ── Positioning (shared positioner) ──────────────────────────────────────
    const positioner = createDropdownPositioner(
      () => fieldEl,
      () => dropdownEl,
    );

    const listNavigation = createListNavigation<ComboboxOptionItem>({
      getIndex: () => focusedIndex.value,
      getItems: () => filteredOptions.value,
      isItemDisabled: (option) => option.disabled,
      setIndex: (index) => {
        focusedIndex.value = index;
        scrollFocusedIntoView();
      },
    });

    const overlay = createOverlayControl({
      getBoundaryElement: () => host,
      getPanelElement: () => dropdownEl,
      getTriggerElement: () => inputEl,
      isDisabled: () => isDisabled.value,
      isOpen: () => isOpen.value,
      positioner: {
        floating: () => dropdownEl,
        reference: () => fieldEl,
        update: () => positioner.updatePosition(),
      },
      restoreFocus: false,
      setOpen: (next, _context) => {
        isOpen.value = next;

        if (!next) listNavigation.reset();
      },
    });

    const applyNavigationResult = (result: ListNavigationResult): void => {
      if (result.reason === 'empty' || result.reason === 'no-enabled-item') {
        focusedIndex.value = -1;
      }
    };

    // ── Open / Close ─────────────────────────────────────────────────────────
    function open(clearFilter = true, reason: OverlayOpenReason = 'programmatic') {
      if (clearFilter) query.value = '';

      overlay.open({ reason });
    }

    function close(reason: 'escape' | 'programmatic' | 'outside-click' | 'toggle' = 'programmatic') {
      overlay.close({ reason, restoreFocus: false });

      // In single mode restore the query to the selected label (or clear)
      if (!isMultiple.value) {
        const match = allOptions.value.find((o) => o.value === selectedValue.value);

        query.value = match?.label ?? '';
      } else {
        query.value = '';
      }

      triggerValidation('blur');
    }
    // ── Selection ────────────────────────────────────────────────────────────
    function selectOption(opt: ComboboxOptionItem, originalEvent?: Event) {
      if (opt.disabled) return;

      if (isMultiple.value) {
        selectionController.toggle(opt.value);
        syncMultipleFormValue();
        query.value = '';
        emitChange(originalEvent);
        triggerValidation('change');
        // Keep dropdown open in multiple mode
        focusLiveInput();
        requestAnimationFrame(() => focusLiveInput());
      } else {
        selectionController.select(opt.value);
        query.value = opt.label;
        formValue.value = opt.value;
        emitChange(originalEvent);
        triggerValidation('change');
        close();
        focusLiveInput();
      }
    }
    function clearValue(e: Event) {
      e.stopPropagation();
      selectionController.clear();
      query.value = '';
      formValue.value = '';
      emitChange(e);
      triggerValidation('change');
      focusLiveInput();
    }
    function handleInput(e: Event) {
      const target = e.target as HTMLInputElement;

      query.value = target.value;

      if (!isMultiple.value) selectionController.clear();

      applyNavigationResult(listNavigation.first());

      if (!isOpen.value) open(false, 'trigger');

      emit('search', { query: target.value } as { query: string });
    }
    function handleFocus() {
      if (!isOpen.value) open(false, 'trigger');
    }
    // ── Keyboard Navigation ──────────────────────────────────────────────────
    function handleKeydown(e: KeyboardEvent) {
      if (isDisabled.value) return;

      const opts = filteredOptions.value;

      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault();

          if (!isOpen.value) {
            open(true, 'trigger');

            applyNavigationResult(listNavigation.first());
          } else {
            applyNavigationResult(listNavigation.next());
          }

          break;
        case 'ArrowUp':
          e.preventDefault();

          if (!isOpen.value) {
            open(true, 'trigger');
          } else {
            applyNavigationResult(listNavigation.prev());
          }

          break;
        case 'Backspace':
          // In multiple mode, remove the last chip when the input is empty
          if (isMultiple.value && !query.value && selectedValues.value.length > 0) {
            selectedValues.value = selectedValues.value.slice(0, -1);
            syncMultipleFormValue();
            emitChange(e);
            triggerValidation('change');
          }

          break;
        case 'End':
          if (isOpen.value) {
            e.preventDefault();
            applyNavigationResult(listNavigation.last());
          }

          break;
        case 'Enter':
          e.preventDefault();

          if (isOpen.value && focusedIndex.value >= 0 && focusedIndex.value < opts.length) {
            selectOption(opts[focusedIndex.value], e);
          } else if (isOpen.value && focusedIndex.value === -1 && creatableLabel.value) {
            // Focused on the "create" item
            createOption(creatableLabel.value, e);
          } else if (!isOpen.value) {
            open();
          }

          break;
        case 'Escape':
          e.preventDefault();

          if (isOpen.value) {
            close('escape');
          }

          break;
        case 'Home':
          if (isOpen.value) {
            e.preventDefault();
            applyNavigationResult(listNavigation.first());
          }

          break;
        case 'Tab':
          close('programmatic');
          break;
        default:
          break;
      }
    }
    function scrollFocusedIntoView() {
      if (focusedIndex.value >= 0) {
        domVirtualList.scrollToIndex(focusedIndex.value, { align: 'auto' });

        return;
      }

      if (!listboxEl) return;

      const focusedEl = listboxEl.querySelector<HTMLElement>('[data-focused]');

      focusedEl?.scrollIntoView({ block: 'nearest' });
    }

    // ── Virtualizer ──────────────────────────────────────────────────────────
    const { domVirtualList, setupVirtualizer, updateRenderedItemState } = createComboboxVirtualizer({
      checkIconHTML,
      comboId,
      getDropdownElement: () => dropdownEl,
      getFocusedIndex: () => focusedIndex.peek(),
      getIsMultiple: () => isMultiple.peek(),
      getListboxElement: () => listboxEl,
      getSelectedValue: () => selectedValue.peek(),
      getSelectedValues: () => selectedValues.peek(),
      onSelectOption: selectOption,
      setFocusedIndex: (index) => {
        focusedIndex.value = index;
      },
    });

    // ── Create option ────────────────────────────────────────────────────────
    function createOption(label: string, originalEvent?: Event) {
      const value = makeCreatableValue(label);
      const newOpt: ComboboxOptionItem = { disabled: false, iconEl: null, label, value };

      createdOptions.value = [...createdOptions.value, newOpt];
      selectOption(newOpt, originalEvent);
    }
    // ── Lifecycle ────────────────────────────────────────────────────────────
    onMount(() => {
      fieldEl = inputEl?.closest('.field') as HTMLElement | null;
      dropdownEl = host.shadowRoot?.querySelector<HTMLElement>('.dropdown') ?? null;
      listboxEl = host.shadowRoot?.querySelector<HTMLElement>('[role="listbox"]') ?? null;

      const removeOutsideClick = overlay.bindOutsideClick(document);

      onSlotChange('default', readOptions);
      // Ensure initial light-DOM options are available for immediate keyboard interaction.
      readOptions();
      // Rebuild virtualizer when filtered options or open state changes
      effect(() => {
        const opts = filteredOptions.value;
        const open = isOpen.value;

        if (open && opts.length > 0) {
          requestAnimationFrame(() => setupVirtualizer(opts, open));
        } else {
          domVirtualList.update(opts, false);
        }
      });
      mountLabelSyncStandalone(labelInsetRef, labelOutsideRef, props);
      effect(() => {
        if (listboxEl) {
          // Remove existing state nodes
          for (const el of Array.from(listboxEl.querySelectorAll('.no-results,.no-results-create,.dropdown-loading')))
            el.remove();

          if (isLoading.value) {
            const loadingEl = document.createElement('div');

            loadingEl.className = 'dropdown-loading';
            loadingEl.textContent = 'Loading\u2026';
            listboxEl.prepend(loadingEl);
          } else if (filteredOptions.value.length === 0) {
            if (creatableLabel.value) {
              const createEl = document.createElement('button');

              createEl.type = 'button';
              createEl.className = 'no-results-create';
              createEl.textContent = `Create "${creatableLabel.value}"`;

              // Apply focused state when keyboard nav lands here (focusedIndex === -1 means create row)
              if (focusedIndex.value === -1) createEl.setAttribute('data-focused', '');

              createEl.addEventListener('pointerdown', (e: PointerEvent) => {
                e.preventDefault();
              });

              createEl.addEventListener('click', (e) => {
                e.stopPropagation();
                createOption(creatableLabel.value, e);
              });
              listboxEl.appendChild(createEl);
            } else {
              const noResults = document.createElement('div');

              noResults.className = 'no-results';
              noResults.setAttribute('role', 'presentation');
              noResults.textContent = 'No results found';
              listboxEl.appendChild(noResults);
            }
          }

          // Update focused/selected state on already-rendered items without touching
          // the DOM structure. The virtualizer owns full re-renders via onChange.
          updateRenderedItemState();
        }
      });
      // Keep rendered option selected/focused attributes in sync while the popup stays open.
      watch(
        [isOpen, props.multiple, focusedIndex, selectedValues, selectedValue],
        () => {
          if (!isOpen.value) return;

          updateRenderedItemState();
        },
        { immediate: true },
      );

      return () => {
        domVirtualList.destroy();
        positioner.destroy();
        removeOutsideClick();
      };
    });

    return html`
      <slot></slot>
      <div class="combobox-wrapper" part="wrapper">
        <label
          class="label-outside"
          for="${comboId}"
          id="${labelId}"
          ref=${labelOutsideRef}
          hidden
          part="label"></label>
        <div
          class="field"
          part="field"
          @click="${() => {
            if (!isOpen.value) open(false, 'trigger');

            focusLiveInput();
          }}">
          <label class="label-inset" for="${comboId}" id="${labelId}" ref=${labelInsetRef} hidden part="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.value ? selectedValues.value : []).map(
                    (item) => html`
                      <bit-chip
                        value=${item.value}
                        aria-label=${item.label || item.value}
                        mode="removable"
                        variant="flat"
                        size="sm"
                        color=${() => props.color.value}
                        @remove=${removeChip}>
                        ${item.label || item.value}
                      </bit-chip>
                    `,
                  )}
              </span>
              <input
                ref=${(el: HTMLInputElement | null) => {
                  inputEl = el;

                  if (!el) {
                    fieldEl = null;

                    return;
                  }

                  fieldEl = el.closest('.field') as HTMLElement | null;
                  aria(el, {
                    activedescendant: () => (focusedIndex.value >= 0 ? `${comboId}-opt-${focusedIndex.value}` : null),
                    autocomplete: 'list',
                    controls: () => `${comboId}-listbox`,
                    describedby: () => (props.error.value || props.helper.value ? helperId : null),
                    disabled: () => isDisabled.value,
                    expanded: () => (isOpen.value ? 'true' : 'false'),
                    invalid: () => !!props.error.value,
                    labelledby: () => (hasLabel.value ? labelId : null),
                  });
                }}
                class="input"
                part="input"
                type="text"
                role="combobox"
                autocomplete="off"
                spellcheck="false"
                id="${comboId}"
                name="${() => props.name.value}"
                placeholder=${() => inputPlaceholder.value}
                :disabled="${() => isDisabled.value}"
                @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.value}
              @click="${clearValue}">
              ${clearIcon}
            </button>
            <span class="chevron" aria-hidden="true">
              ${chevronDownIcon}
              <span class="loader" aria-label="Loading"></span>
            </span>
          </div>
        </div>

        <div class="dropdown" part="dropdown" id="${() => `${comboId}-dropdown`}" ?data-open=${() => isOpen.value}>
          <div
            role="listbox"
            id="${() => `${comboId}-listbox`}"
            aria-label="${() => props.label.value || props.placeholder.value || 'Options'}"></div>
        </div>

        <span
          class="helper-text"
          id="${helperId}"
          part="helper-text"
          aria-live="polite"
          ?hidden=${() => assistiveText.value.hidden}
          style=${() => (assistiveText.value.isError ? 'color: var(--color-error);' : '')}
          >${() => assistiveText.value.text}</span
        >
      </div>
    `;
  },
  shadow: { delegatesFocus: true },
  styles: [
    sizeVariantMixin(FIELD_SIZE_PRESET),
    ...formFieldMixins,
    disabledLoadingMixin(),
    forcedColorsFocusMixin('.input'),
    componentStyles,
  ],
  tag: 'bit-combobox',
}) 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.

PreviewCode
RTL

Variants

Five visual variants for different UI contexts and levels of emphasis.

PreviewCode
RTL

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.

PreviewCode
RTL

Colors

PreviewCode
RTL

Sizes

PreviewCode
RTL

Label Placement

The label can be placed inset (inside the field, above the input — default) or outside (above the field border).

PreviewCode
RTL

Clearable

Add clearable to show a clear (×) button whenever a value is selected.

PreviewCode
RTL

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.

PreviewCode
RTL

In multiple mode the change event detail includes both value (comma-separated) and values (array):

js
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

PreviewCode
RTL

Disabled Options

Add the disabled attribute on a <bit-combobox-option> to prevent selection of individual options.

PreviewCode
RTL

Disabled State

PreviewCode
RTL

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.

PreviewCode
RTL

For a real server-side integration, replace options dynamically on bit-input:

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

PreviewCode
RTL
js
document.querySelector('bit-combobox').addEventListener('change', (e) => {
  // Both selected and newly created options fire change
  console.log('Selected/created:', e.detail.value, e.detail.label);
});

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.

PreviewCode
RTL
js
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 is an object with value, label, and optional disabled fields.

js
const cb = document.querySelector('bit-combobox');
cb.options = [
  { value: 'ts', label: 'TypeScript' },
  { 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.

PreviewCode
RTL
js
document.getElementById('myForm').addEventListener('submit', (e) => {
  e.preventDefault();
  const data = new FormData(e.target);
  console.log('country:', data.get('country'));
});

Listening to Events

PreviewCode
RTL
js
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, 'label:', e.detail.label);
  // 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);
});

API Reference

bit-combobox Attributes

AttributeTypeDefaultDescription
valuestring''Currently selected value
namestring''Form field name
labelstring''Label text
label-placement'inset' | 'outside''inset'Label positioning
placeholderstring''Input placeholder text
helperstring''Helper text shown below the field
errorstring''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
clearablebooleanfalseShow a clear button when a value is selected
no-filterbooleanfalseDisable client-side option filtering (for server-side use)
creatablebooleanfalseShow a "Create X" option when no match is found
loadingbooleanfalseShow a loading indicator in the dropdown
multiplebooleanfalseAllow selecting multiple options (chips are shown in field)
fullwidthbooleanfalseExpand to fill the container width
disabledbooleanfalseDisable the control

bit-combobox Slots

SlotDescription
(default)bit-combobox-option elements defining the option list

bit-combobox-option Attributes

AttributeTypeDefaultDescription
valuestring''The value submitted to the form and emitted in bit-change
labelstringExplicit text used for display and filtering; falls back to the element's textContent
disabledbooleanfalsePrevent this option from being selected

bit-combobox-option Slots

SlotDescription
iconOptional leading icon or decoration
(default)Label text for the option

Events

EventDetailDescription
change{ value: string, values: string[], label: string }Emitted when a value is selected from the listbox
search{ query: string }Emitted on every keystroke in the text input

CSS Custom Properties

PropertyDescriptionDefault
--combobox-font-sizeInput / option font size--text-sm
--combobox-gapGap between field elements--size-2
--combobox-paddingField paddingInner paddings
--combobox-radiusField border radius--rounded-lg
--combobox-bgField background color--color-contrast-100
--combobox-border-colorDefault border color--color-contrast-300

Accessibility

The combobox component follows WCAG 2.1 Level AA standards.

bit-combobox

Keyboard Navigation

  • Tab focuses the input; ArrowDown / ArrowUp navigate the option list.
  • Enter confirms selection; Escape closes the dropdown.

Screen Readers

  • Uses role="combobox" with aria-expanded, aria-controls, aria-activedescendant, and aria-autocomplete="list".
  • The dropdown uses role="listbox"; each option uses role="option" with aria-selected and aria-disabled.
  • aria-live="polite" on the helper / error region announces validation messages.
  • aria-labelledby links the label; aria-describedby links helper and error text.
  • aria-disabled reflects the disabled state.

Best Practices

Do:

  • Use placeholder to hint at the expected input (e.g. "Search countries…").
  • Use clearable when the field is optional and users might want to reset their choice.
  • Use no-filter + bit-input for server-side search with large datasets.
  • Pair with error text and color="error" for form validation feedback.

Don't:

  • Use combobox for short lists (< 6 items) — a plain bit-select is simpler.
  • Rely on the dropdown alone: always provide a visible label so the purpose is clear when the list is hidden.