A pattern that combines readonly combobox with multi-enabled listbox to create multiple-selection dropdowns with keyboard navigation and screen reader support.
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
<div ngCombobox readonly>
<div #origin class="select">
<span class="combobox-label">
<span
class="selected-label-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ displayIcon() }}</span
>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 3.5rem;
height: 2.5rem;
border: none;
}
[ngCombobox]:focus-within .select {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 0.5rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 11rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
<div ngCombobox class="material-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span
class="selected-label-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ displayIcon() }}</span
>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
border: none;
cursor: pointer;
height: 3rem;
padding: 0 3.5rem;
}
[ngCombobox]:focus-within .select {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 2rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 13rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
}
[ngOption]:hover,
[ngOption][data-active='true'] {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid var(--primary);
}
[ngOption][aria-selected='true'] {
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
<div ngCombobox class="retro-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span
class="selected-label-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ displayIcon() }}</span
>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
}
.select:hover,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 6rem;
height: 2.5rem;
border: none;
}
.select:has([ngComboboxInput][aria-expanded='false']):focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
The multiselect pattern works best when users need to choose multiple related items from a familiar set of options.
Consider using this pattern when:
Avoid this pattern when:
The multiselect pattern combines Combobox and Listbox directives to provide a fully accessible dropdown with:
Users need to select multiple items from a list of options. A readonly combobox paired with a multi-enabled listbox provides familiar multiselect functionality with full accessibility support.
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = ['Important', 'Starred', 'Work', 'Personal', 'To Do', 'Later', 'Read', 'Travel'];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
<div ngCombobox readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 2.5rem;
height: 2.5rem;
border: none;
}
[ngCombobox]:focus-within .select {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 1.5rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 0.5rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 11rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = ['Important', 'Starred', 'Work', 'Personal', 'To Do', 'Later', 'Read', 'Travel'];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
<div ngCombobox class="material-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
[ngComboboxInput] {
opacity: 0;
border: none;
cursor: pointer;
height: 3rem;
padding: 0 2.5rem;
}
[ngCombobox]:focus-within .select {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 1.5rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 2rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 13rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
}
[ngOption]:hover,
[ngOption][data-active='true'] {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid var(--primary);
}
[ngOption][aria-selected='true'] {
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = ['Important', 'Starred', 'Work', 'Personal', 'To Do', 'Later', 'Read', 'Travel'];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
<div ngCombobox class="retro-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label) {
<div ngOption [value]="label" [label]="label">
<span class="example-option-text">{{ label }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
}
.select:hover,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 5rem;
height: 2.5rem;
border: none;
}
.select:has([ngComboboxInput][aria-expanded='false']):focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
The multi attribute on ngListbox enables multiple selection. Press Space to toggle options, and the popup remains open for additional selections. The display shows the first selected item plus a count of remaining selections.
Options often need visual indicators like icons or colors to help users identify choices. Custom templates within options allow rich formatting while the display value shows a compact summary.
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
<div ngCombobox readonly>
<div #origin class="select">
<span class="combobox-label">
<span
class="selected-label-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ displayIcon() }}</span
>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 3.5rem;
height: 2.5rem;
border: none;
}
[ngCombobox]:focus-within .select {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 0.5rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 11rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
<div ngCombobox class="material-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span
class="selected-label-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ displayIcon() }}</span
>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
border: none;
cursor: pointer;
height: 3rem;
padding: 0 3.5rem;
}
[ngCombobox]:focus-within .select {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 2rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 13rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
}
[ngOption]:hover,
[ngOption][data-active='true'] {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid var(--primary);
}
[ngOption][aria-selected='true'] {
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
signal,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The icon that is displayed in the combobox. */
displayIcon = computed(() => {
const values = this.listbox()?.values() || [];
const label = this.labels.find((label) => label.value === values[0]);
return label ? label.icon : '';
});
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select a label';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} + ${values.length - 1} more`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', icon: 'label'},
{value: 'Starred', icon: 'star'},
{value: 'Work', icon: 'work'},
{value: 'Personal', icon: 'person'},
{value: 'To Do', icon: 'checklist'},
{value: 'Later', icon: 'schedule'},
{value: 'Read', icon: 'menu_book'},
{value: 'Travel', icon: 'flight'},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
}
<div ngCombobox class="retro-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span
class="selected-label-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ displayIcon() }}</span
>
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value">
<span
class="example-option-icon material-symbols-outlined"
translate="no"
aria-hidden="true"
>{{ label.icon }}</span
>
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
}
.select:hover,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 6rem;
height: 2.5rem;
border: none;
}
.select:has([ngComboboxInput][aria-expanded='false']):focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
Each option displays an icon alongside its label. The display value updates to show the first selection's icon and text, followed by a count of additional selections. Selected options show a checkmark for clear visual feedback.
Forms sometimes need to limit the number of selections or validate user choices. Programmatic control over selection enables these constraints while maintaining accessibility.
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select 2 labels';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} & ${values[1]}`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', disabled: computed(() => this.isOptionDisabled('Important'))},
{value: 'Starred', disabled: computed(() => this.isOptionDisabled('Starred'))},
{value: 'Work', disabled: computed(() => this.isOptionDisabled('Work'))},
{value: 'Personal', disabled: computed(() => this.isOptionDisabled('Personal'))},
{value: 'To Do', disabled: computed(() => this.isOptionDisabled('To Do'))},
{value: 'Later', disabled: computed(() => this.isOptionDisabled('Later'))},
{value: 'Read', disabled: computed(() => this.isOptionDisabled('Read'))},
{value: 'Travel', disabled: computed(() => this.isOptionDisabled('Travel'))},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
isOptionDisabled(value: string) {
const values = this.listbox()?.values();
if (!values || values.length < 2) {
return false;
}
return !values.includes(value);
}
}
<div ngCombobox readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value" [disabled]="label.disabled()">
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.select {
display: flex;
position: relative;
align-items: center;
color: color-mix(in srgb, var(--hot-pink) 90%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, var(--hot-pink) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--hot-pink) 15%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 2.5rem;
height: 2.5rem;
border: none;
}
[ngCombobox]:focus-within .select {
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
.combobox-label {
gap: 1rem;
left: 1.5rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 0.5rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 11rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
margin: 1px;
padding: 0 1rem;
min-height: 2.25rem;
border-radius: 0.5rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid color-mix(in srgb, var(--hot-pink) 50%, transparent);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
[ngOption][aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
[ngOption][aria-disabled='true']:hover {
background-color: transparent;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select 2 labels';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} & ${values[1]}`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', disabled: computed(() => this.isOptionDisabled('Important'))},
{value: 'Starred', disabled: computed(() => this.isOptionDisabled('Starred'))},
{value: 'Work', disabled: computed(() => this.isOptionDisabled('Work'))},
{value: 'Personal', disabled: computed(() => this.isOptionDisabled('Personal'))},
{value: 'To Do', disabled: computed(() => this.isOptionDisabled('To Do'))},
{value: 'Later', disabled: computed(() => this.isOptionDisabled('Later'))},
{value: 'Read', disabled: computed(() => this.isOptionDisabled('Read'))},
{value: 'Travel', disabled: computed(() => this.isOptionDisabled('Travel'))},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
isOptionDisabled(value: string) {
const values = this.listbox()?.values();
if (!values || values.length < 2) {
return false;
}
return !values.includes(value);
}
}
<div ngCombobox class="material-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value" [disabled]="label.disabled()">
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--primary: var(--hot-pink);
--on-primary: var(--page-background);
}
.docs-light-mode {
--on-primary: #fff;
}
.select {
display: flex;
position: relative;
align-items: center;
border-radius: 3rem;
color: var(--on-primary);
background-color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 80%, transparent);
}
.select:hover {
background-color: color-mix(in srgb, var(--primary) 90%, transparent);
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
[ngComboboxInput] {
opacity: 0;
border: none;
cursor: pointer;
height: 3rem;
padding: 0 2.5rem;
}
[ngCombobox]:focus-within .select {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.combobox-label {
gap: 1rem;
left: 1.5rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 8px;
border-radius: 2rem;
background-color: var(--septenary-contrast);
font-size: 0.9rem;
max-height: 13rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
padding: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
min-height: 3rem;
border-radius: 3rem;
}
[ngOption]:hover,
[ngOption][data-active='true'] {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px solid var(--primary);
}
[ngOption][aria-selected='true'] {
color: var(--primary);
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
[ngOption][aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
[ngOption][aria-disabled='true']:hover {
background-color: transparent;
}
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
import {
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
} from '@angular/aria/combobox';
import {Listbox, Option} from '@angular/aria/listbox';
import {
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
viewChild,
viewChildren,
} from '@angular/core';
import {OverlayModule} from '@angular/cdk/overlay';
@Component({
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
imports: [
Combobox,
ComboboxInput,
ComboboxPopup,
ComboboxPopupContainer,
Listbox,
Option,
OverlayModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
/** The combobox listbox popup. */
listbox = viewChild<Listbox<string>>(Listbox);
/** The options available in the listbox. */
options = viewChildren<Option<string>>(Option);
/** A reference to the ng aria combobox. */
combobox = viewChild<Combobox<string>>(Combobox);
/** The string that is displayed in the combobox. */
displayValue = computed(() => {
const values = this.listbox()?.values() || [];
if (values.length === 0) {
return 'Select 2 labels';
}
if (values.length === 1) {
return values[0];
}
return `${values[0]} & ${values[1]}`;
});
/** The labels that are available for selection. */
labels = [
{value: 'Important', disabled: computed(() => this.isOptionDisabled('Important'))},
{value: 'Starred', disabled: computed(() => this.isOptionDisabled('Starred'))},
{value: 'Work', disabled: computed(() => this.isOptionDisabled('Work'))},
{value: 'Personal', disabled: computed(() => this.isOptionDisabled('Personal'))},
{value: 'To Do', disabled: computed(() => this.isOptionDisabled('To Do'))},
{value: 'Later', disabled: computed(() => this.isOptionDisabled('Later'))},
{value: 'Read', disabled: computed(() => this.isOptionDisabled('Read'))},
{value: 'Travel', disabled: computed(() => this.isOptionDisabled('Travel'))},
];
constructor() {
// Scrolls to the active item when the active option changes.
// The slight delay here is to ensure animations are done before scrolling.
afterRenderEffect(() => {
const option = this.options().find((opt) => opt.active());
setTimeout(() => option?.element.scrollIntoView({block: 'nearest'}), 50);
});
// Resets the listbox scroll position when the combobox is closed.
afterRenderEffect(() => {
if (!this.combobox()?.expanded()) {
setTimeout(() => this.listbox()?.element.scrollTo(0, 0), 150);
}
});
}
isOptionDisabled(value: string) {
const values = this.listbox()?.values();
if (!values || values.length < 2) {
return false;
}
return !values.includes(value);
}
}
<div ngCombobox class="retro-select" readonly>
<div #origin class="select">
<span class="combobox-label">
<span class="selected-label-text">{{ displayValue() }}</span>
</span>
<input aria-label="Label dropdown" placeholder="Select a label" ngComboboxInput />
<span class="example-arrow material-symbols-outlined" translate="no" aria-hidden="true"
>arrow_drop_down</span
>
</div>
<ng-template ngComboboxPopupContainer>
<ng-template
[cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}"
[cdkConnectedOverlayOpen]="true"
>
<div class="example-popup-container">
<div ngListbox multi>
@for (label of labels; track label.value) {
<div ngOption [value]="label.value" [label]="label.value" [disabled]="label.disabled()">
<span class="example-option-text">{{ label.value }}</span>
<span
class="example-option-check material-symbols-outlined"
translate="no"
aria-hidden="true"
>check</span
>
</div>
}
</div>
</div>
</ng-template>
</ng-template>
</div>
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-size: 0.8rem;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--hot-pink) 80%, var(--page-background));
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.select {
display: flex;
position: relative;
align-items: center;
color: var(--page-background);
background-color: var(--hot-pink);
box-shadow: var(--retro-clickable-shadow);
}
.select:hover,
.select:focus-within {
transform: translate(1px, 1px);
}
.select:active {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--gray-50));
}
.select:has([ngComboboxInput][aria-disabled='true']) {
opacity: 0.6;
cursor: default;
}
.selected-label-icon {
font-size: 1.25rem;
}
[ngComboboxInput] {
opacity: 0;
cursor: pointer;
padding: 0 5rem;
height: 2.5rem;
border: none;
}
.select:has([ngComboboxInput][aria-expanded='false']):focus-within {
outline-offset: 8px;
outline: 4px dashed var(--hot-pink);
}
.combobox-label {
gap: 1rem;
left: 1rem;
display: flex;
position: absolute;
align-items: center;
pointer-events: none;
}
.example-arrow {
right: 1rem;
position: absolute;
pointer-events: none;
transition: transform 150ms ease-in-out;
}
[ngComboboxInput][aria-expanded='true'] ~ .example-arrow {
transform: rotate(180deg);
}
.example-popup-container {
width: 100%;
padding: 0.5rem;
margin-top: 20px;
box-shadow: var(--retro-flat-shadow);
background-color: var(--septenary-contrast);
max-height: 11rem;
opacity: 1;
visibility: visible;
transition:
max-height 150ms ease-out,
visibility 0s,
opacity 25ms ease-out;
}
[ngListbox] {
gap: 2px;
height: 100%;
display: flex;
overflow: auto;
flex-direction: column;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='false']) .example-popup-container {
max-height: 0;
opacity: 0;
visibility: hidden;
transition:
max-height 150ms ease-in,
visibility 0s 150ms,
opacity 150ms ease-in;
}
[ngCombobox]:has([ngComboboxInput][aria-expanded='true']) [ngListbox] {
display: flex;
}
[ngOption] {
display: flex;
cursor: pointer;
align-items: center;
padding: 0 1rem;
font-size: 0.6rem;
min-height: 2.25rem;
}
[ngOption]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 5%, transparent);
}
[ngOption][data-active='true'] {
outline-offset: -2px;
outline: 2px dashed var(--hot-pink);
}
[ngOption][aria-selected='true'] {
color: var(--hot-pink);
background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);
}
.example-option-icon {
font-size: 1.25rem;
padding-right: 1rem;
}
[ngOption]:not([aria-selected='true']) .example-option-check {
display: none;
}
[ngOption][aria-disabled='true'] {
opacity: 0.6;
cursor: default;
}
[ngOption][aria-disabled='true']:hover {
background-color: transparent;
}
.example-option-icon,
.example-option-check {
font-size: 0.9rem;
}
.example-option-text {
flex: 1;
}
This example limits selections to three items. When the limit is reached, unselected options become disabled, preventing additional selections. A message informs users about the constraint.
The multiselect pattern uses the following directives from Angular's Aria library. See the full API documentation in the linked guides.
The multiselect pattern uses ngCombobox with the readonly attribute to prevent text input while preserving keyboard navigation.
| Property | Type | Default | Description |
|---|---|---|---|
readonly | boolean | false | Set to true to create dropdown behavior |
disabled | boolean | false | Disables the entire multiselect |
See the Combobox API documentation for complete details on all available inputs and signals.
The multiselect pattern uses ngListbox with the multi attribute for multiple selection and ngOption for each selectable item.
| Property | Type | Default | Description |
|---|---|---|---|
multi | boolean | false | Set to true to enable multiple selection |
| Property | Type | Description |
|---|---|---|
values | any[] | Two-way bindable array of selected values |
When multi is true, users can select multiple options using Space to toggle selection. The popup remains open after selection, allowing additional choices.
See the Listbox API documentation for complete details on listbox configuration, selection modes, and option properties.
The multiselect pattern integrates with CDK Overlay for smart positioning. Use cdkConnectedOverlay to handle viewport edges and scrolling automatically.
Super-powered by Google ©2010–2025.
Code licensed under an MIT-style License. Documentation licensed under CC BY 4.0.
https://angular.dev/guide/aria/multiselect