Combobox
An autocomplete input with a list of suggestions. Filters as you type, highlights matches, and supports remote data through a debounced search event with loading and result-count states.
Preview
<esa-combobox label="State" placeholder="Pick a state"></esa-combobox> import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-combobox';
@Component({
selector: 'app-combobox-demo',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<esa-combobox label="State" placeholder="Pick a state"></esa-combobox>
`,
})
export class ComboboxDemo {} Combobox or Select?
If the user types into it, it is a Combobox. If they pick from what you gave them, it is a Select.
- Combobox — an input with suggestions. Filters as you type,
highlights the match, and can fetch remotely: debounced
search,loading,results-count. - Select — a button opens a list, no text entry. Distant options are reached by typeahead.
mode and trigger-style are deprecated.
mode="select" rendered a button trigger over a fixed list — that is a
Select, under this component's name — and it was the default until
2026-08-15, so the default combobox was a select. Both still work and warn at
runtime; trigger-style only ever styled that button trigger.
Sizes
<esa-combobox label="Extra small" size="xs"></esa-combobox>
<esa-combobox label="Small" size="sm"></esa-combobox>
<esa-combobox label="Medium" size="md"></esa-combobox>
<esa-combobox label="Large" size="lg"></esa-combobox> import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-combobox';
@Component({
selector: 'app-combobox-demo',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<esa-combobox label="Extra small" size="xs"></esa-combobox>
<esa-combobox label="Small" size="sm"></esa-combobox>
<esa-combobox label="Medium" size="md"></esa-combobox>
<esa-combobox label="Large" size="lg"></esa-combobox>
`,
})
export class ComboboxDemo {} States
Multi-select renders chips. Loading, results-count, error, and disabled states are attribute-driven.
<esa-combobox label="States visited" multiple placeholder="Add states"></esa-combobox>
<esa-combobox label="With results count" results-count="50" help-text="Shows 'Displaying X of Y results'"></esa-combobox>
<esa-combobox label="Loading" loading></esa-combobox>
<esa-combobox label="Error" error-text="Selection required"></esa-combobox>
<esa-combobox label="Disabled" disabled></esa-combobox> import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-combobox';
@Component({
selector: 'app-combobox-demo',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<esa-combobox label="States visited" multiple placeholder="Add states"></esa-combobox>
<esa-combobox
label="With results count"
results-count="50"
help-text="Shows 'Displaying X of Y results'"
></esa-combobox>
<esa-combobox label="Loading" loading></esa-combobox>
<esa-combobox label="Error" error-text="Selection required"></esa-combobox>
<esa-combobox label="Disabled" disabled></esa-combobox>
`,
})
export class ComboboxDemo {} API
| Prop | Type | Default | Description |
|---|---|---|---|
mode | 'autocomplete' | 'select' | 'autocomplete' | DEPRECATED — 'select' renders a button trigger over a fixed list, which is <a href='/components/esa-select'>esa-select</a>. Still honoured; warns at runtime. |
trigger-style | 'field' | 'text' | 'field' | DEPRECATED — only ever styled the mode='select' button trigger, so it goes with it. Property: `triggerStyle`. |
options | EsaComboboxOption[] | [] | Selectable options. Set as a property. |
multiple | boolean | false | Allow multiple selections with removable chips. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'md' | Control size (reflected attribute). |
label | string | '' | Field label above the control. |
placeholder | string | 'Select...' | Placeholder for the trigger/input. |
disabled | boolean | false | Disables interaction (reflected attribute). |
name | string | undefined | — | Form field name — the key this control submits under. |
required | boolean | false | Marks the field required. |
live-error | boolean | false | Announce the error the moment it appears rather than only when the field is focused. OFF by default — see the long note on `esa-text-field.liveError`: the house pattern is validate-on-submit with `<esa-error-summary>`, under which a live region per field fires an assertive announcement for EVERY invalid field at once, racing the summary the user was just sent to. Turn it on for fields validated INLINE, on blur. Property: `liveError`. |
help-text | string | '' | Helper text below the control. Property: `helpText`. |
cue | string | '' | Override the visually-hidden instructional cue read to screen reader users. The default explains that results filter as you type and how to review them — which is what makes announcing every keystroke unnecessary. Replace it if this combobox behaves differently (server-side search with a delay, say); do not blank it, or the results list becomes silent with nothing to set expectations. |
error-text | string | '' | Error message; sets the error state when present. Property: `errorText`. |
loading | boolean | false | Shows a spinner and "Searching..." state. |
debounce-ms | number | 300 | Debounce window for the search event. Property: `debounceMs`. |
results-count | number | null | null | When set, renders "Displaying X of Y results". Property: `resultsCount`. |
value | string | string[] | null | — | Property accessor. Single = string; multiple = string array. Set as a JS property — there is no attribute. |
Events
| Event | Type | Default | Description |
|---|---|---|---|
change | CustomEvent<{ value: string | string[] }> | — | Fired on selection change. Composed and bubbles. |
search | CustomEvent<{ term: string }> | — | Debounced search term (300ms default), de-duplicated. Composed and bubbles. |
Binding these events from Angular
Every one of these is a CustomEvent, so the payload is on
$event.detail — $event.target.value reads empty, with no
error. The names are lowercase and Angular passes them to
addEventListener verbatim, so camel-casing one means it never fires.
(change)="onChange($event.detail)"(search)="onSearch($event.detail)"
Accessibility
- Autocomplete input carries
role="combobox"witharia-expanded,aria-haspopup="listbox", andaria-autocomplete="list". - Panel is
role="listbox"; options arerole="option"witharia-selected. - Keyboard: ArrowUp/Down navigate, Enter selects, Escape/Tab close. Outside-click closes.
- Matched substrings are wrapped in
<mark>for visible highlight. - Form-associated via
ElementInternals— value submits with the enclosing form.
Theming surface
Every public token this component reads, extracted from its source at build time,
split by blast radius: the hooks that belong to this component,
then the shared and system tokens it consumes alongside everything else. The
Resolves to column walks each token's real lineage —
component → semantic → primitive → raw value — so you
can see exactly where re-pointing it lands. A spoke re-skins by overriding these in
its theme-<slug>.css; component internals are never edited. No
hook for what you need? File it with /request-lego.
Type roles it names
This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.
| Role | Re-point | Reach |
|---|---|---|
.typography-body-2xs |
| +6 more componentsesa-checkbox, esa-checkbox-group, esa-input-tag, esa-progress-bar, esa-radio-group, esa-textarea |
.typography-body-lg |
| +7 more componentsesa-breadcrumbs, esa-checkbox, esa-checkbox-group, esa-input-tag, esa-radio-group, esa-select, esa-textarea |
.typography-body-md |
| +28 more componentsesa-app-shell, esa-breadcrumbs, esa-card, esa-checkbox, esa-checkbox-group, esa-collapsible, esa-command-palette, esa-confirm-dialog, esa-container, esa-danger-zone, esa-dialog, esa-dropdown-menu, esa-empty-state, esa-entity-search, esa-form-field, esa-header-nav, esa-input-tag, esa-nav-dropdown, esa-page-header, esa-popover, esa-progress-bar, esa-radio-group, esa-select, esa-side-dialog, esa-snackbar-item, esa-switch-toggle, esa-tab-layout, esa-textarea |
.typography-body-sm |
| +20 more componentsesa-alert-box, esa-breadcrumbs, esa-button-toggle, esa-card, esa-checkbox-group, esa-command-palette, esa-date-picker, esa-empty-state, esa-file-upload, esa-form-field, esa-input-tag, esa-link-column, esa-loading-overlay, esa-progress-bar, esa-radio-group, esa-search-panel, esa-select, esa-stat, esa-text-field, esa-textarea |
.typography-body-xs |
| +14 more componentsesa-breadcrumbs, esa-checkbox, esa-checkbox-group, esa-command-palette, esa-empty-state, esa-entity-search, esa-field-error, esa-header-nav, esa-input-tag, esa-progress-bar, esa-radio-group, esa-search-panel, esa-select, esa-textarea |
.typography-label-2xs |
| +11 more componentsesa-button-toggle, esa-checkbox-group, esa-color-picker, esa-date-picker, esa-form-field, esa-input-tag, esa-progress-bar, esa-radio-group, esa-range-slider, esa-text-field, esa-textarea |
.typography-label-lg |
| +12 more componentsesa-breadcrumbs, esa-button-toggle, esa-checkbox-group, esa-color-picker, esa-date-picker, esa-form-field, esa-input-tag, esa-radio-group, esa-range-slider, esa-select, esa-text-field, esa-textarea |
.typography-label-md |
| +33 more componentsesa-app-bar, esa-breadcrumbs, esa-button-group, esa-button-toggle, esa-card, esa-checkbox-group, esa-color-picker, esa-command-palette, esa-confirm-dialog, esa-danger-zone, esa-date-picker, esa-dialog, esa-empty-state, esa-entity-search, esa-file-upload, esa-filter-container, esa-form-field, esa-header-nav, esa-input-tag, esa-link-column, esa-page-header, esa-pillbox, esa-popover, esa-progress-bar, esa-radio-group, esa-range-slider, esa-select, esa-side-dialog, esa-sidebar-nav, esa-stat, esa-text-field, esa-textarea, esa-tooltip |
.typography-label-xs |
| +14 more componentsesa-breadcrumbs, esa-button-toggle, esa-checkbox-group, esa-color-picker, esa-date-picker, esa-entity-search, esa-form-field, esa-input-tag, esa-progress-bar, esa-radio-group, esa-range-slider, esa-select, esa-text-field, esa-textarea |
.typography-microcopy-2xs-subtle |
| +3 more componentsesa-date-picker, esa-input-tag, esa-text-field |
.typography-microcopy-lg-subtle |
| +7 more componentsesa-command-palette, esa-date-picker, esa-entity-search, esa-filter-dropdown, esa-input-tag, esa-select, esa-text-field |
.typography-microcopy-md-subtle |
| +7 more componentsesa-date-picker, esa-filter-dropdown, esa-input-tag, esa-pagination, esa-search-panel, esa-select, esa-text-field |
.typography-microcopy-xs-subtle |
| +7 more componentsesa-date-picker, esa-filter-dropdown, esa-filter-pills, esa-input-tag, esa-select, esa-text-field, esa-tooltip |
Wired to this component
None. This component has no hooks of its own — it themes entirely through the shared tokens below, so every override here also moves its siblings.
Shared tokens it reads
Owned by a family or by the semantic layer. Still overridable — but scope the
override (esa-foo { --form-border-color: … }) unless you mean to move
everything that reads it. Reach says what else that is.
| Token | Reach | Resolves to |
|---|---|---|
--focus-ring-color | component focus ring group · +34 more | #3e9b4f via --color-border-default-focus → --color-grass-10 |
--focus-ring-offset | component focus ring group · +31 more | 2px |
--focus-ring-width | component focus ring group · +35 more | 2px via --border-width-focus → --border-width-200 |
--form-border-color | component forms group · +11 more | one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half.
*
* This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus via --color-border-default |
--form-border-color-focus | component forms group · +11 more | #3e9b4f via --color-border-default-focus → --color-grass-10 |
--form-border-width | component forms group · +10 more | 1px via --border-width-default → --border-width-100 |
--form-error-border-color | component forms group · +9 more | #ce2c31 via --color-background-utility-danger → --color-red-11 |
--form-error-color | component forms group · +9 more | #ce2c31 via --color-content-utility-danger → --color-red-11 |
--form-help-color | component forms group · +9 more | #646464 via --color-content-default-secondary → --color-gray-11 |
--form-label-color | component forms group · +7 more | #646464 via --color-content-default-secondary → --color-gray-11 |
--form-placeholder-color | component forms group · +4 more | #646464 via --color-content-default-secondary → --color-gray-11 |
--form-text-color | component forms group · +6 more | #202020 via --color-content-default → --color-gray-12 |
--animation-spin | semantic every component | 750ms via --duration-750 |
--border-width-default | semantic every component | 1px via --border-width-100 |
--color-background-disabled | semantic every component | #f0f0f0 via --color-gray-3 |
--color-background-elevation-raised | semantic every component | #fcfcfc via --color-gray-1 |
--color-background-elevation-sunken | semantic every component | #f0f0f0 via --color-gray-3 |
--color-background-field | semantic every component | rgba(0, 0, 0, 0) |
--color-background-overlay-active | semantic every component | #46a758 via --color-background-brand → --color-grass-9 |
--color-background-overlay-strong-hover | semantic every component | rgba(0, 0, 0, 0.05) via --color-black-a-1 |
--color-background-utility-warning-subtle | semantic every component | #fefdfb via --color-yellow-1 |
--color-border-default | semantic every component | one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half.
*
* This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus |
--color-border-default-subtle | semantic every component | #d9d9d9 via --color-gray-6 |
--color-border-disabled | semantic every component | #d9d9d9 via --color-gray-6 |
--color-content-brand | semantic every component | #2a7e3b via --color-grass-11 |
--color-content-default | semantic every component | #202020 via --color-gray-12 |
--color-content-default-secondary | semantic every component | #646464 via --color-gray-11 |
--color-content-disabled | semantic every component | #8d8d8d via --color-gray-9 |
--color-content-utility-danger | semantic every component | #ce2c31 via --color-red-11 |
--elevation-4 | semantic every component | 0 via --shadow-offset-x |
--radius-md | semantic every component | 0.5rem via --radius-200 |
--radius-pill | semantic every component | 9999px via --radius-full |
--radius-sm | semantic every component | 0.25rem via --radius-100 |
--transition-fast | semantic every component | 150ms via --duration-150 |
--z-dropdown | semantic every component | 50 |
--font-style-italic | primitive every component | italic |
--icon-size-sm | primitive every component | 16px |
--spacing-050 | primitive every component | 0.125rem |
--spacing-100 | primitive every component | 0.25rem |
--spacing-200 | primitive every component | 0.5rem |
--spacing-250 | primitive every component | 0.625rem |
--spacing-300 | primitive every component | 0.75rem |
--spacing-400 | primitive every component | 1rem |