import { Component } from '@angular/core';
import { WrOption, WrOptionGroup, WrOptionLeading, WrSelect } from 'ngwr/select';
@Component({ imports: [WrOption, WrOptionGroup, WrOptionLeading, WrSelect] })
export class MyComponent {}Select
Pick one or several values from a list, with search and free-text tags.
Installation
Import the class, and @use the entry point in your global styles: without it the component renders unstyled.
Basic usage
[(value)] holds the picked option's value.
<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>Groups
Label related options with <wr-option-group>.
<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 />Width
A select is 10rem wide by default. Change that with --wr-select-min-width; a min-width on .wr-select__trigger has no effect.
Multi mode
The multi mode makes the value an array, shown as chips. clearable adds a clear-all button.
<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] folds extra chips into +N more; [maxItems] caps how many can be picked.
<wr-select mode="multi" [maxTagCount]="2" [maxItems]="4" [(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 filters the options as you type. clearable adds a clear button and lets Backspace clear the value.
<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
Add searchable to multi for a filter field beside the chips.
<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>Leading visuals and an explicit label
Put an avatar or icon in an <ng-template wrOptionLeading>, not straight into the option. Set label when the row shows more than the name.
<wr-select placeholder="Assign to" [(value)]="assignee">
@for (p of people; track p.id) {
<wr-option [value]="p.id" [label]="p.name">
<ng-template wrOptionLeading let-placement="placement">
<wr-avatar shape="circle" [size]="avatarPx[placement]">{{ p.initials }}</wr-avatar>
</ng-template>
{{ p.name }} <small>{{ p.team }}</small>
</wr-option>
}
</wr-select>
<!-- Search -->
<wr-select mode="search" clearable placeholder="Search people" [(value)]="reviewer">
@for (p of people; track p.id) { … the same options … }
</wr-select>
<!-- Multi -->
<wr-select mode="multi" searchable clearable placeholder="Add assignees" [(value)]="assignees">
@for (p of people; track p.id) { … the same options … }
</wr-select>
<!-- [options], [loader] and virtualScroll rows: a template directly in <wr-select>. -->
<wr-select mode="search" virtualScroll [options]="people" [displayWith]="name">
<ng-template wrOptionLeading let-person let-placement="placement">
<wr-avatar shape="circle" [size]="avatarPx[placement]">{{ person.initials }}</wr-avatar>
</ng-template>
</wr-select>import { WrAvatar } from 'ngwr/avatar';
import { WrOption, WrOptionLeading, type WrOptionLeadingPlacement, WrSelect } from 'ngwr/select';
@Component({ imports: [WrSelect, WrOption, WrOptionLeading, WrAvatar] })
export class AssigneePicker {
// One template, three places: size it per placement.
protected readonly avatarPx: Record<WrOptionLeadingPlacement, number> = { option: 24, value: 20, chip: 16 };
}Server-side search
Fetch on (searchChange), pass the results to [options] and [loading], and add serverSearch to turn off the local filter.
<wr-select
mode="search"
serverSearch
placeholder="Search a country"
[options]="results()"
[loading]="pending()"
[(value)]="picked"
(searchChange)="onSearch($event)"
/>// Debounced by [debounceMs]; emits '' when the field is cleared.
onSearch(query: string): void {
this.store.dispatch(searchCountries({ query }));
}Keyboard
Arrow keys move the highlight, Home and End jump to the ends, Enter picks, Escape closes. Focus stays on the trigger.
Search mode — virtual scroll
Add virtualScroll for a long [options] array. Projected <wr-option>s are not virtualized.
<wr-select
mode="search"
virtualScroll
placeholder="Search 5,000 items"
[options]="bigOptions"
[(value)]="picked"
/>Tag mode
The tag mode turns typed text into chips. [separators] commit (Enter and comma by default), [validate] filters, [maxItems] caps.
<wr-select mode="tag" placeholder="Add a tag" [(value)]="freeTags" />
<!-- Custom separators, a validator and a cap -->
<wr-select
mode="tag"
placeholder="Add email and press Enter or ,"
[(value)]="recipients"
[separators]="['Enter', ',', ' ']"
[validate]="isEmail"
[maxItems]="5"
/>Use it in a form
[formField], formControlName and [(ngModel)] all bind value. See Forms.
<!-- Signal Forms -->
<wr-select [formField]="form.framework">
<wr-option value="angular">Angular</wr-option>
<wr-option value="react">React</wr-option>
</wr-select>
<!-- Reactive forms -->
<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 -->
<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
Values match with ===, so a re-fetched object shows the placeholder: 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>// Never matches: the trigger shows the placeholder, 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);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. | boolean | false |
size | Control size — shares the --wr-control-* contract. Read by the trigger's host classes AND by the overlay panel, which builds its size modifier separately. | WrSelectSize | 'md' |
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 |
panelClass | Extra CSS classes for the dropdown panel's overlay pane. The pane is appended to the overlay container, not to this component, so nothing in the consumer's own template encloses it and no descendant rule written around the trigger can reach it. This input is the only per-instance handle on it. A space-separated string works as well as an array. The pane is a positioning wrapper carrying the gap above the list; the box that paints is .wr-select-panel inside it, so a rule meant for the surface reads .my-class .wr-select-panel. | WrClassInput | null |
maxItems | Cap on selected items (multi mode). 0 = unlimited. Once reached, the unselected options are marked unavailable — aria-disabled and the disabled look — and refuse a pick until one is removed. | 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. Emits '' when cleared; [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 |
label | What the trigger, chips and search use for this option. Defaults to the option’s text. | string | null | null |
Option leading template
| Name | Description | Type | Default |
|---|---|---|---|
[wrOptionLeading] | On an <ng-template> inside a <wr-option>, or directly in <wr-select> for every row, which [options], [loader] and virtual rows need. | TemplateRef<WrOptionLeadingContext> | — |
$implicit | The option’s value — let-value. | T | — |
placement | Where it is drawn: 'option' (a panel row), 'chip' (a multi chip) or 'value' (the trigger). | 'option' | 'chip' | 'value' | — |
Option Group API
| Name | Description | Type | Default |
|---|---|---|---|
labelrequired | Section heading. | string | — |
CSS variables
Set these on the component's selector or a wrapper, not on :root: the defaults are declared on ngwr/select's own element.
| Variable | Default | Declared on |
|---|---|---|
--wr-option-font-size | var(--wr-control-font-size-md) | .wr-select-panel +2 variant overrides |
--wr-option-font-weight | var(--wr-font-weight-medium) | .wr-select-panel |
--wr-option-gap | 0.1875rem | .wr-select-panel +2 variant overrides |
--wr-option-group-border | var(--wr-border-base) | .wr-option-group |
--wr-option-group-label-font-size | var(--wr-text-xs) | .wr-option-group |
--wr-option-group-label-font-weight | var(--wr-font-weight-semibold) | .wr-option-group |
--wr-option-group-label-padding | var(--wr-space-sm) var(--wr-space-md) var(--wr-space-xs) | .wr-option-group |
--wr-option-group-margin-top | var(--wr-space-xs) | .wr-option-group |
--wr-option-group-padding-top | var(--wr-space-xs) | .wr-option-group |
--wr-option-inline-gap | var(--wr-space-sm) | .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 | var(--wr-space-md) | .wr-select-panel +2 variant overrides |
--wr-option-padding-y | var(--wr-space-sm) | .wr-select-panel +2 variant overrides |
--wr-option-radius | var(--wr-border-radius-sm) | .wr-select-panel |
--wr-select-bg | var(--wr-color-surface) | .wr-select +1 variant override |
--wr-select-border | var(--wr-border-base) | .wr-select |
--wr-select-chip-font-size | var(--wr-text-sm) | .wr-select |
--wr-select-chip-gap | var(--wr-space-xs) | .wr-select +1 variant override |
--wr-select-chip-line-height | var(--wr-leading-snug) | .wr-select |
--wr-select-chip-padding-block | var(--wr-space-xs) | .wr-select +1 variant override |
--wr-select-chip-padding-inline-end | var(--wr-space-xs) | .wr-select +1 variant override |
--wr-select-chip-padding-inline-start | var(--wr-space-sm) | .wr-select +1 variant override |
--wr-select-chips-gap | var(--wr-space-xs) | .wr-select +1 variant override |
--wr-select-clear-radius | var(--wr-border-radius-pill) | .wr-select |
--wr-select-color | var(--wr-color-on-surface) | .wr-select +1 variant override |
--wr-select-font-family | var(--wr-font-family-base) | .wr-select |
--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-overlay-padding-top | var(--wr-space-xs) | .wr-select-overlay |
--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-border-base) | .wr-select-panel |
--wr-select-panel-font-family | var(--wr-font-family-base) | .wr-select-panel |
--wr-select-panel-max-height | 16rem | .wr-select-panel |
--wr-select-panel-message-padding | var(--wr-space-md) | .wr-select-panel |
--wr-select-panel-padding | var(--wr-space-xs) | .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 |
--wr-select-tag-input-padding | var(--wr-space-xs) | .wr-select |
--wr-select-trigger-gap | var(--wr-space-sm) | .wr-select |
Dependencies
| Angular CDK | overlay, portal |
|---|---|
| Built on | Form, Overlay |
| Used by | Pagination, Schema Form |
See also
Component CascaderPick a path through nested options, one column per level.Component TreeA nested list to browse and select from, inline or as a dropdown picker.Component TransferMove items between two lists; the right one is the value.Component RadioPick one option from a short list.Component Form FieldWraps a form control with a label, a hint and its error messages.Guide FormsBind ngwr controls to any Angular form and show their errors.Component FormA plain layout wrapper for a label, a control and its error.Guide OverlayStyle ngwr's floating panels and control where focus goes.