Actual CSS

Combobox

A searchable or multi-value combobox is not shipped by the framework — the native <select> and <datalist> stay the baseline. Theme an external combobox with Actual tokens instead.

The .select class keeps the native baseline with a CSS chevron, and the optional custom-select module adds the appearance: base-select native picker where the browser supports it. Beyond those, apply the framework's judgment from the data grid template to the problem: when a real product needs a JavaScript combobox (searchable select, removable tags, autocomplete suggestions), load a dedicated component and bridge its custom properties onto Actual's tokens.

Related terms: combo box, combobox, autocomplete, datalist, select, custom select, native select, searchable select, enhanced select, multiple select, dropdown, form controls, tags, chips, tag input, pill input.

Theme bridge

The combobox template themes @lekoala/combobox entirely with Actual tokens: the control mirrors .input, chips are real .badge elements, and the picker popover follows --surface-raised and --shadow-popout. No adapter, no compatibility layer — a handful of --cb-* assignments and shared mix tokens.

<label class="field actual-combobox">
  <span class="field-label">Skills</span>
  <combo-box chip-class="badge primary" placeholder="Search or add a skill…">
    <select class="select" name="skills[]" multiple>
      <option value="design" selected>Design</option>
      <option value="css" selected>CSS</option>
      <option value="javascript" selected>JavaScript</option>
    </select>
  </combo-box>
</label>

chip-class hands the chip skin to the host classes, so badge variants and theme rules on .badge apply; the widget keeps layout, removal and keyboard focus. Set the default before creating comboboxes, after loading the CDN script:

customElements.get("combo-box").setDefaultChipClass("badge primary");

With a bundler, use Combobox.setDefaultChipClass("badge primary"). The chip-class attribute overrides the default for one element.

The card in the template documents the full token map of this recipe.

Bridge contract

Actual owns the visual language; the widget owns the behavior. Actual provides surface, border, radius, inset focus outline, danger state, .join corners, and the contrast contracts those tokens carry. The widget provides search, keyboard navigation, multi-value state, removable tags, and option rendering. The bridge CSS stays in docs/demo — it is a recipe, not package API, so no actual-combobox.css ships with the framework and no widget version is pinned by it.

Multi-value and i18n (for example a "n selected" summary string) are widget configuration: pass locale strings and tag rendering through the widget's own options, and keep skinning on the --cb-* tokens so light/dark mode and preset themes keep applying for free.

Lightweight pass-through

Most of the skin is three one-line token bridges. The visible vocabulary — surface, border, radius, focus ring, danger, hover overlay and the --soft-* mix — stays the Actual one, so light/dark mode, the preset themes and their contrast contracts apply to the combobox for free:

.actual-combobox {
  --cb-bg: var(--control-default-bg, var(--surface));
  --cb-color: var(--text);
  --cb-border-color: var(--form-invalid-border, var(--border));
  --cb-border-radius: var(--radius);
  --cb-focus-color: var(--focus);
  --cb-muted-color: var(--text-muted);
  --cb-error-color: var(--danger);
  --cb-control-height: var(--control-size);
  --cb-option-min-height: var(--control-size);
}

.actual-combobox :is(.cb-text-control:focus-visible, .cb-control:has(.cb-input:focus-visible)) {
  border-color: var(--form-invalid-border, var(--cb-border-color));
  outline: var(--focus-ring-width) solid var(--form-invalid-border, var(--focus));
  outline-offset: calc(var(--focus-ring-width) * -1);
  box-shadow: var(--control-shadow, none);
}

.actual-combobox :is(.cb-text-control[aria-invalid="true"], .cb-control:has([aria-invalid="true"])) {
  --form-invalid-border: var(--danger);
}

:where(.actual-combobox) combo-box {
  border-radius: var(--cb-border-radius);
}

.actual-combobox :is(.cb-control, .cb-text-control) {
  border-width: var(--border-width);
  border-radius: inherit;
  box-shadow: var(--control-shadow, none);
}

.actual-combobox :is(.cb-control, .cb-text-control):hover {
  border-color: var(--form-invalid-border, var(--control-hover-border, var(--text-subtle)));
}

The control reads the field geometry a theme gives .input: --border-width and the --control-shadow elevation, which focus keeps. Hover follows .input too (--control-hover-border), since the control is typed into, in place of the library's own border mix. While the search input is focused, the control takes the same inset outline as .input in place of the library's accent border and outer halo; a focused chip or remove × keeps the library's own inner ring. Being inset, the outline needs no adaptation inside .join: combo-box owns the radius at zero specificity, so the join gives it the group corners and the visible control inherits them. The invalid rule sets --form-invalid-border, the hook .field.danger and aria-invalid already use, so border and outline turn danger together.

Limits

The bridge shares the inherited resting field fill through --control-default-bg. It follows tokens, not native-control selectors: local --control-bg overrides and theme-specific invalid or disabled fills need matching widget state rules.

The same approach works for any widget that exposes a custom-property skin — keep the widget's behavior, restyle its tokens through the shared vocabulary.

Actual CSS

Search documentation