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
<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.
<wr-select responsive placeholder="Pick a size">…</wr-select>Groups
Use <wr-option-group> to label sections of related options.
<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.
<!-- 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.
<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.
<!-- 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.
<!-- 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.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"
/>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.
<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),
});
}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
| Name | Description | Type | Default |
|---|---|---|---|
placeholder | Placeholder shown when no option is selected. Falls back to select.placeholder. | string | null | null |
clearLabel | Clear-selection (×) button aria-label. Falls back to select.clearSelection. | string | null | null |
ariaLabel | Accessible name of the trigger. Falls back to the placeholder, then to select.label — role="combobox" on an empty trigger otherwise has no name at all, which is the common case before anything is selected. | string | null | null |
disabled | Disable the select. Bound automatically from the field's disabled state when used with [formField]. | boolean | false |
readonly | Refuse 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. | boolean | false |
rounded | Pill-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 | null | false |
size | Control size — shares the --wr-control-* contract. Unset falls back to the select.size app default from provideWrConfig(). | WrSelectSize | null | 'md' |
responsive | Present 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 | undefined | undefined |
mode | Behavior 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 | null | null |
searchable | Add 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. | boolean | false |
searchQuery | The 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 | '' |
options | Search 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[] | [] |
displayWith | Search mode: map a dynamic option item to its display label. | (item: unknown) => string | String |
loader | Search 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> | null | null |
serverSearch | The 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. | boolean | false |
debounceMs | Search mode: debounce (ms) applied to the loader. | number | 250 |
minChars | Search mode: minimum query length before the panel opens / loader fires. | number | 0 |
freeText | Search mode: allow values not in the options list. Enter on an unmatched query commits the raw text as the form value. | boolean | false |
virtualScroll | Search 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. | boolean | false |
rowHeight | Search 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. | number | 0 |
viewportHeight | Search mode: height of the virtual scroll viewport — a number (px) or any CSS length. | number | string | 256 |
overscan | Search mode: extra rows kept above/below the virtual viewport. | number | 6 |
noResultsText | Search mode: text shown when the filter / loader returns nothing. Falls back to select.noResults. | string | null | null |
loadingText | Search mode: text shown while the async loader is in flight. Falls back to select.loading. | string | null | null |
(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 | — |
loading | Show 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. | boolean | false |
separators | Tag 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', ','] |
allowDuplicates | Tag mode: allow the same value to appear more than once. | boolean | false |
validate | Tag mode: custom validator — return true to accept the value, false to silently reject. Receives the trimmed draft + the existing chips. | WrSelectTagValidator | null | null |
clearable | Show 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. | boolean | false |
maxItems | Cap on selected items (multi mode). 0 = unlimited. Once reached, additional clicks on unselected options are ignored. | number | 0 |
maxTagCount | Maximum number of chips rendered before collapsing the rest into a +N more indicator. 0 = render every chip. | number | 0 |
value | Unified value. Single mode holds a scalar (or null); multi / tag mode holds readonly unknown[]. Bound by [formField], or two-way via [(value)]. | unknown | null |
(touch) | Emitted on blur / commit so a bound field can mark itself touched. | void | — |
Select outputs
| Name | Description | Type | Default |
|---|---|---|---|
(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
| Name | Description | Type | Default |
|---|---|---|---|
valuerequired | Form value contributed when chosen. | unknown | — |
disabled | Disable this option. | boolean | false |
Option Group API
| Name | Description | Type | Default |
|---|---|---|---|
labelrequired | Section 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.
| Variable | Default | Declared on |
|---|---|---|
--wr-option-font-size | var(--wr-control-font-size-md) | .wr-select-panel +2 variant overrides |
--wr-option-gap | 0.1875rem | .wr-select-panel +2 variant overrides |
--wr-option-inline-gap | 0.5rem | .wr-select-panel +2 variant overrides |
--wr-option-line-height | var(--wr-control-line-height-md) | .wr-select-panel +2 variant overrides |
--wr-option-padding-x | 0.625rem | .wr-select-panel +2 variant overrides |
--wr-option-padding-y | 0.4375rem | .wr-select-panel +2 variant overrides |
--wr-select-bg | var(--wr-color-surface) | .wr-select +1 variant override |
--wr-select-border | var(--wr-color-outline) | .wr-select |
--wr-select-color | var(--wr-color-on-surface) | .wr-select +1 variant override |
--wr-select-font-size | var(--wr-control-font-size-md) | .wr-select +2 variant overrides |
--wr-select-line-height | var(--wr-control-line-height-md) | .wr-select +2 variant overrides |
--wr-select-min-width | 10rem | .wr-select |
--wr-select-padding-x | var(--wr-control-padding-x-md) | .wr-select +3 variant overrides |
--wr-select-padding-y | var(--wr-control-padding-y-md) | .wr-select +2 variant overrides |
--wr-select-panel-bg | var(--wr-color-surface) | .wr-select-panel |
--wr-select-panel-border | var(--wr-color-outline) | .wr-select-panel |
--wr-select-panel-max-height | 16rem | .wr-select-panel |
--wr-select-panel-radius | var(--wr-border-radius-base) | .wr-select-panel |
--wr-select-panel-shadow | var(--wr-shadow-overlay) | .wr-select-panel |
--wr-select-radius | var(--wr-control-radius-md) | .wr-select +3 variant overrides |