ComponentStandaloneCDK Overlay

Select

Native-like select built on CDK Overlay. A signal-forms native control — it implements FormValueControl, so [formField] binds straight to its value model. [(value)] works standalone, and [(ngModel)] / reactive forms keep working through Angular 22's forms bridge.

Installation

import { WrSelect, WrOption, WrOptionGroup } from 'ngwr/select';
import { FormsModule } from '@angular/forms';

// WrOptionGroup only if you use <wr-option-group>; FormsModule only for
// [(ngModel)]. For reactive forms bring ReactiveFormsModule instead — see
// "Use it in a form" below.
@Component({ imports: [WrSelect, WrOption, WrOptionGroup, FormsModule] })
export class MyComponent {}

Basic usage

Value: none
<wr-select placeholder="Pick a size" [(value)]="size">
  <wr-option value="sm">Small</wr-option>
  <wr-option value="md">Medium</wr-option>
  <wr-option value="lg">Large</wr-option>
  <wr-option value="xl" disabled>Extra large — out of stock</wr-option>
</wr-select>

Responsive (bottom-sheet)

With responsive — or app-wide via provideWrResponsiveOverlays() — the option panel detaches from the trigger and slides up as a full-width bottom-sheet on small viewports, with a backdrop. Open this on a phone (or narrow the window below 640px) to see it dock to the bottom.

Value: none
<wr-select responsive placeholder="Pick a size">…</wr-select>

Groups

Use <wr-option-group> to label sections of related options.

Value: angular
<wr-select [(value)]="framework">
  <wr-option-group label="Frontend">
    <wr-option value="angular">Angular</wr-option>
    <wr-option value="react">React</wr-option>
    <wr-option value="vue">Vue</wr-option>
  </wr-option-group>
  <wr-option-group label="Backend">
    <wr-option value="nest">NestJS</wr-option>
    <wr-option value="fastify">Fastify</wr-option>
  </wr-option-group>
</wr-select>

Disabled

<wr-select placeholder="Disabled" disabled />

Multi mode

Set the mode input to 'multi' to switch the model to T[]. Each pick renders as a chip with a remove button; the panel stays open while you keep picking. Backspace on the closed trigger removes the last chip. Add clearable — opt-in, and what the demo below passes — for the clear-all (×) that appears once at least one chip is selected.

Value: [ "typescript", "angular" ]
<!-- clearable is opt-in: without it the trigger has no clear-all (×). -->
<wr-select mode="multi" clearable placeholder="Pick tags" [(value)]="tags">
  <wr-option value="typescript">TypeScript</wr-option>
  <wr-option value="angular">Angular</wr-option>
  <wr-option value="rxjs">RxJS</wr-option>
  <wr-option value="signals">Signals</wr-option>
</wr-select>

Chip overflow + max items

[maxTagCount] collapses extra chips into a +N more indicator on the trigger so long selections don't wrap forever. [maxItems] caps the underlying array.

Value: [ "typescript", "angular", "rxjs", "signals" ]
<wr-select mode="multi" [maxTagCount]="2" [maxItems]="6" [(value)]="manyTags">
  <wr-option value="typescript">TypeScript</wr-option>
  <wr-option value="angular">Angular</wr-option>
  <wr-option value="rxjs">RxJS</wr-option>
  <wr-option value="signals">Signals</wr-option>
  <wr-option value="cdk">CDK</wr-option>
  <wr-option value="ssr">SSR</wr-option>
</wr-select>

Search mode

The search mode turns the trigger into a text input that filters the projected <wr-option> children case-insensitively. clearable is opt-in and off by default; the demo below passes it, which is what puts the × on the trigger and makes Backspace on an empty field unset the value — the × was otherwise the only way to clear one here, which made it a mouse-only action. Replaces the removed wr-autocomplete for the common sync-filter case.

Picked:
<!-- clearable is opt-in: it paints the × and gates Backspace-to-clear. -->
<wr-select mode="search" clearable placeholder="Search a country" [(value)]="country">
  @for (c of countries; track c) {
    <wr-option [value]="c">{{ c }}</wr-option>
  }
</wr-select>

Searchable multi-select

[mode] picks one shape, so searchable exists alongside it: on multi the trigger keeps its chips and grows an inline text field. Type to filter, Enter to toggle the highlighted option, Backspace on an empty query to drop the last chip. The panel stays open so you can pick several matches without retyping.

Cranes
Value: [ "Cranes" ]
<!-- searchable is orthogonal to mode — this is multi + typeahead. -->
<wr-select mode="multi" searchable placeholder="Filter categories" [(value)]="categories">
  @for (c of allCategories; track c) {
    <wr-option [value]="c">{{ c }}</wr-option>
  }
</wr-select>

Server-side search

(searchChange) is the hook when the options live somewhere else: it emits the debounced query, you dispatch, and the results come back through [options] with [loading] driving the panel's progress row. Add serverSearch so the built-in client-side filter stays out of the way — otherwise a ranked or fuzzy match whose label doesn't contain the query would be hidden again. The demo below fakes 700 ms of latency.

<!-- Options live in the store; (searchChange) is the only wiring. -->
<wr-select
  mode="search"
  serverSearch
  placeholder="Search a country"
  [options]="results()"
  [loading]="pending()"
  [(value)]="picked"
  (searchChange)="onSearch($event)"
/>
// (searchChange) is debounced by [debounceMs] and fires on every settled
// change — including '' when the field is cleared, so the store can reset.
onSearch(query: string): void {
  this.store.dispatch(searchCountries({ query }));
}

// results()/pending() are plain store selectors.
Picked:

Keyboard, and where the highlight goes

Both panels are driven by aria-activedescendant, so the highlight moves without focus leaving the trigger. Arrow Down and Arrow Up step, Home and End jump to the ends, Enter picks the highlighted option, Escape closes and returns focus to the trigger, and typing filters in search mode. The highlighted option is always scrolled into view, in the plain panel as well as the virtual one — that held only for the virtual path until v14.0.1, so a keyboard user in a long non-virtual list drove a cursor they could not see.

Search mode — virtual scroll

For large [options] arrays, add virtualScroll so only ~one viewport of rows stays in the DOM. It engages on the data-array path only (falls back to the full render when static <wr-option> children are projected); rows are navigated via aria-activedescendant and selected by value. Type to filter, Arrow / Home / End / Enter to pick. The list below holds 5,000 items.

<!-- 5,000 options via the [options] data array + virtualScroll:
     only ~one viewport of rows is ever in the DOM. -->
<wr-select
  mode="search"
  virtualScroll
  placeholder="Search 5,000 items"
  [options]="bigOptions"
  [(value)]="picked"
/>
Picked:

Tag mode

The tag mode turns the trigger into a chip area with an inline input. Press one of [separators] (default: Enter or comma) to commit the draft. Backspace on an empty input removes the last chip. Replaces the removed wr-chips-input.

ngwrzoneless
Value: [ "ngwr", "zoneless" ] Emails: [ "[email protected]" ] (validated; max 5)
<wr-select mode="tag" placeholder="Add a tag" [(value)]="tags" />

<!-- With separators + validator + caps -->
<wr-select
  mode="tag"
  placeholder="Add email and press Enter or ,"
  [(value)]="recipients"
  [separators]="['Enter', ',', ' ']"
  [validate]="isEmail"
  [maxItems]="5"
/>

Use it in a form

All three flavours bind the same value model. Signal forms is the native one — [formField] reaches it directly. Reactive forms and [(ngModel)] go through Angular 22's bridge, which binds a signal-forms control without a ControlValueAccessor: the library ships none, and formControlName still works. That bridge is not an accessor and does not behave like one — the reactive-forms guide is what it carries, what it drops (updateOn, and a { emitEvent: false } write), and what a host does about each. The demo is a reactive form — submit it empty to see the required error, then pick a value.

<!-- Signal forms — the native path. `[formField]` binds the field to
     the component's own `value` model. -->
<wr-select [formField]="form.framework">
  <wr-option value="angular">Angular</wr-option>
  <wr-option value="react">React</wr-option>
</wr-select>

<!-- Reactive forms — no ControlValueAccessor anywhere, and none needed.
     Angular 22 binds `formControlName` / `[formControl]` straight to the
     control's `value` model, relays its `touch` output as markAsTouched(),
     and writes `disabled` back down from the FormControl. -->
<form [formGroup]="form">
  <wr-form-field label="Framework" required>
    <wr-select formControlName="framework" placeholder="Pick one">
      <wr-option value="angular">Angular</wr-option>
      <wr-option value="react">React</wr-option>
    </wr-select>
    <wr-form-error key="required">Pick a framework.</wr-form-error>
  </wr-form-field>
</form>

<!-- Template-driven — the same bridge. -->
<wr-select [(ngModel)]="framework" name="framework">…</wr-select>
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';

import { WrFormError, WrFormField } from 'ngwr/form';
import { WrOption, WrSelect } from 'ngwr/select';

@Component({
  imports: [ReactiveFormsModule, WrFormField, WrFormError, WrSelect, WrOption],
  templateUrl: './my.html',
})
export class MyComponent {
  protected readonly form = new FormGroup({
    framework: new FormControl<string | null>(null, Validators.required),
  });
}
Control value: null · touched: false

How a value matches an option

With ===, against each <wr-option [value]>. There is no compareWith input and no identity key — so an object bound as the value has to be the *same reference* the option carries.

This is the one thing to get right before binding <wr-select> to server data. An object that arrives from a second request is structurally equal to the one in your options list and is not the same reference, so nothing matches: the trigger falls back to the placeholder while the form value stays exactly what you set, and valid. A field that looks empty on a form that submits happily is a hard bug to read backwards, so the rule is worth following by default — bind a primitive key.

<!-- Bind the key, not the object. -->
<wr-select formControlName="categoryId" placeholder="Pick a category">
  @for (cat of categories(); track cat.id) {
    <wr-option [value]="cat.id">{{ cat.name }}</wr-option>
  }
</wr-select>
// The option matches when `option.value === select.value`, nothing more.

// This never matches, and fails silently in both directions: the trigger shows
// the placeholder, the form value stays {id: 2, …} and the form stays valid.
const category = new FormControl<Category | null>(null);
category.setValue({ id: 2, name: 'Laptops' });

// This matches.
const categoryId = new FormControl<number | null>(null);
categoryId.setValue(2);

If the value genuinely has to be the object, keep one canonical instance per id in whatever the options render from, so the reference the form holds is the reference the option carries. In search mode there is one softening worth knowing: a value with no rendered option falls back to displayWith(value) for the trigger label, because a virtualized row may not exist yet — so a mismatch there shows a label rather than the placeholder, and hides itself better. The reactive-forms guide covers this alongside the rest of the bridge.

Select API

NameDescriptionTypeDefault
placeholderPlaceholder shown when no option is selected. Falls back to select.placeholder.string | nullnull
clearLabelClear-selection (×) button aria-label. Falls back to select.clearSelection.string | nullnull
ariaLabelAccessible name of the trigger. Falls back to the placeholder, then to select.labelrole="combobox" on an empty trigger otherwise has no name at all, which is the common case before anything is selected.string | nullnull
disabledDisable the select. Bound automatically from the field's disabled state when used with [formField].booleanfalse
readonlyRefuse changes while the trigger stays focusable and the value still submits. Bound automatically from the field's readonly state when used with [formField]. The panel is where every edit happens, so a read-only select does not open — and with it go the clear button, the chip ×, the tag draft and the search query. The text-input shapes take the native readonly attribute, which is what keeps them focusable and selectable where disabled would not; every shape mirrors aria-readonly, which role combobox supports.booleanfalse
roundedPill-shaped corners on the trigger. Unset falls back to the select.rounded app default from provideWrConfig(); [rounded]="false" turns a configured true back off.boolean | nullfalse
sizeControl size — shares the --wr-control-* contract. Unset falls back to the select.size app default from provideWrConfig().WrSelectSize | null'md'
responsivePresent the option panel as a full-width bottom-sheet on small viewports instead of an anchored dropdown. undefined follows the app-wide provideWrResponsiveOverlays() setting; true/false overrides it.boolean | undefinedundefined
modeBehavior mode. <wr-select> is the unified combobox primitive — pick the shape via [mode]: - 'single' (default) — one value, no input. Classic dropdown. - 'multi' — array value, chips on the trigger. - 'search' — type-ahead with sync filter or async loader. - 'tag' — free-text + chips.WrSelectMode | nullnull
searchableAdd a type-ahead filter without changing the value shape — the missing multi-with-search combination, since [mode] picks exactly one shape: ``html <wr-select mode="multi" searchable [(value)]="categories">…</wr-select> ` On multi the trigger keeps its chips and grows an inline text field (the shape tag mode already uses); on single it is equivalent to mode="search". Every search input ([options], [loader], [debounceMs], [minChars], [virtualScroll], the (searchChange) output, …) applies either way. Ignored in tag` mode, which owns its own input.booleanfalse
searchQueryThe live search query. Empty string = no filter. Exposed via context so each <wr-option> can self-hide non-matching rows. Two-way bindable, so the query can be owned or reset from outside: [(searchQuery)]="query". For server-side search prefer the debounced {@link searchChange} output — searchQueryChange fires on every keystroke.string''
optionsSearch mode: dynamic option array. Each item is rendered as a <wr-option> whose label comes from [displayWith]. Works alongside projected <wr-option> children — both lists are filtered by the search query.readonly unknown[][]
displayWithSearch mode: map a dynamic option item to its display label.(item: unknown) => stringString
loaderSearch mode: async loader. When set, the loader is called on every (debounced) keystroke and its result replaces [options]. Supports Observables, Promises, and plain arrays.WrSelectSearchLoader<unknown> | nullnull
serverSearchThe option list is already scoped to the query upstream — skip the built-in client-side label filter. Set it when [options] is fed from a server via the {@link searchChange} output; the async [loader] path implies it. Without this, a server that ranks or fuzzy-matches (returning rows whose labels don't literally contain the query) would have those rows hidden again on the client.booleanfalse
debounceMsSearch mode: debounce (ms) applied to the loader.number250
minCharsSearch mode: minimum query length before the panel opens / loader fires.number0
freeTextSearch mode: allow values not in the options list. Enter on an unmatched query commits the raw text as the form value.booleanfalse
virtualScrollSearch mode: window the option panel so thousands of [options] keep only ~one viewport of rows in the DOM. Opt-in and OFF by default. Engages ONLY on the pure data-array search tier — it silently falls back to the full render when static <wr-option> children are projected or the filtered list is empty. While on, rows are plain role="option" elements (no <wr-option>), navigated via aria-activedescendant, selected by value.booleanfalse
rowHeightSearch mode: uniform option-row height in px for the virtual window. 0 (default) measures the first rendered row once and reuses it, so it adapts to the control size. Read only when virtualScroll engages.number0
viewportHeightSearch mode: height of the virtual scroll viewport — a number (px) or any CSS length.number | string256
overscanSearch mode: extra rows kept above/below the virtual viewport.number6
noResultsTextSearch mode: text shown when the filter / loader returns nothing. Falls back to select.noResults.string | nullnull
loadingTextSearch mode: text shown while the async loader is in flight. Falls back to select.loading.string | nullnull
(searchChange)Debounced search query, gated to searchable selects. Emits on every settled change — including the empty string when the field is cleared — so a store-backed option list can stay in sync and reset itself. [debounceMs] controls the delay; [minChars] does NOT gate it, for the same reason the [loader] path clears its results below that threshold. This is the hook for server-side search: dispatch on (searchChange), feed the results back through [options] (plus [serverSearch]), and drive the panel's progress row with [loading]. Prefer it over the model's raw (searchQueryChange), which fires on every keystroke.string
loadingShow the panel's progress row. Independent of [loader], which raises and lowers its own flag — use this when the options come from a store fed by the {@link searchChange} output.booleanfalse
separatorsTag mode: keys / characters that commit the current draft into a chip. 'Enter' is the key name; everything else is a literal character watched in keypresses and pastes.readonly string[]['Enter', ',']
allowDuplicatesTag mode: allow the same value to appear more than once.booleanfalse
validateTag mode: custom validator — return true to accept the value, false to silently reject. Receives the trimmed draft + the existing chips.WrSelectTagValidator | nullnull
clearableShow a clear (×) button once at least one option is selected. NOT multi-only, which this said for a long time and which the template refutes: only the button trigger gates on isMulti(), so the chips trigger AND a single mode="search" / [searchable] select carry it too. The wording mattered little while the default was true; it decides what a reader expects to lose now that it is not. The default changed from true to false: the affordance is opt-in, so a trigger that grew an × on its own no longer does — pass clearable to keep it. In search mode this also gates Backspace-to-clear on an empty field, since that key is the keyboard twin of this button.booleanfalse
maxItemsCap on selected items (multi mode). 0 = unlimited. Once reached, additional clicks on unselected options are ignored.number0
maxTagCountMaximum number of chips rendered before collapsing the rest into a +N more indicator. 0 = render every chip.number0
valueUnified value. Single mode holds a scalar (or null); multi / tag mode holds readonly unknown[]. Bound by [formField], or two-way via [(value)].unknownnull
(touch)Emitted on blur / commit so a bound field can mark itself touched.void

Select outputs

NameDescriptionTypeDefault
(searchChange)Debounced query, for server-side search. Fires on every settled change — including '' when cleared, so a store can reset. [minChars] does not gate it.string
(searchQueryChange)Raw, undebounced query — the [(searchQuery)] half. Prefer (searchChange) for server calls.string
(valueChange)The [(value)] half. Bound automatically by [formField] / [(ngModel)].unknown
(touch)Emitted on blur / commit so a bound field marks itself touched.void

Option API

NameDescriptionTypeDefault
valuerequiredForm value contributed when chosen.unknown
disabledDisable this option.booleanfalse

Option Group API

NameDescriptionTypeDefault
labelrequiredSection heading.string

CSS variables

Custom properties ngwr/select publishes. Each default below is declared on the component's own selector, so a :root override is shadowed by it — set them on that selector, on a wrapper you scope yourself, or inline on the element. Unlike the BEM class names, these are the supported way to restyle the component.

VariableDefaultDeclared on
--wr-option-font-sizevar(--wr-control-font-size-md).wr-select-panel +2 variant overrides
--wr-option-gap0.1875rem.wr-select-panel +2 variant overrides
--wr-option-inline-gap0.5rem.wr-select-panel +2 variant overrides
--wr-option-line-heightvar(--wr-control-line-height-md).wr-select-panel +2 variant overrides
--wr-option-padding-x0.625rem.wr-select-panel +2 variant overrides
--wr-option-padding-y0.4375rem.wr-select-panel +2 variant overrides
--wr-select-bgvar(--wr-color-surface).wr-select +1 variant override
--wr-select-bordervar(--wr-color-outline).wr-select
--wr-select-colorvar(--wr-color-on-surface).wr-select +1 variant override
--wr-select-font-sizevar(--wr-control-font-size-md).wr-select +2 variant overrides
--wr-select-line-heightvar(--wr-control-line-height-md).wr-select +2 variant overrides
--wr-select-min-width10rem.wr-select
--wr-select-padding-xvar(--wr-control-padding-x-md).wr-select +3 variant overrides
--wr-select-padding-yvar(--wr-control-padding-y-md).wr-select +2 variant overrides
--wr-select-panel-bgvar(--wr-color-surface).wr-select-panel
--wr-select-panel-bordervar(--wr-color-outline).wr-select-panel
--wr-select-panel-max-height16rem.wr-select-panel
--wr-select-panel-radiusvar(--wr-border-radius-base).wr-select-panel
--wr-select-panel-shadowvar(--wr-shadow-overlay).wr-select-panel
--wr-select-radiusvar(--wr-control-radius-md).wr-select +3 variant overrides