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.