Combobox
Overview
A directive that coordinates a text input with a popup, providing the primitive directive for autocomplete, select, and multiselect patterns.
app.component.ts
import {Combobox, ComboboxInput, ComboboxPopupContainer} from '@angular/aria/combobox';import {Listbox, Option} from '@angular/aria/listbox';import {OverlayModule} from '@angular/cdk/overlay';import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, signal, viewChild, viewChildren,} from '@angular/core';import {FormsModule} from '@angular/forms';@Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrl: 'app.component.css', imports: [ Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option, OverlayModule, FormsModule, ], 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 query string used to filter the list of countries. */ query = signal(''); /** The list of countries filtered by the query. */ countries = computed(() => ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())), ); 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); } }); }}const ALL_COUNTRIES = [ 'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Antigua and Barbuda', 'Argentina', 'Armenia', 'Australia', 'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium', 'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei', 'Bulgaria', 'Burkina Faso', 'Burundi', 'Cabo Verde', 'Cambodia', 'Cameroon', 'Canada', 'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo (Congo-Brazzaville)', 'Costa Rica', "Côte d'Ivoire", 'Croatia', 'Cuba', 'Cyprus', 'Czechia (Czech Republic)', 'Democratic Republic of the Congo', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic', 'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini (fmr. ""Swaziland"")', 'Ethiopia', 'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada', 'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Holy See', 'Honduras', 'Hungary', 'Iceland', 'India', 'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan', 'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia', 'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives', 'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova', 'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar (formerly Burma)', 'Namibia', 'Nauru', 'Nepal', 'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia', 'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine State', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru', 'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis', 'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe', 'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia', 'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka', 'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Tajikistan', 'Tanzania', 'Thailand', 'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu', 'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States of America', 'Uruguay', 'Uzbekistan', 'Vanuatu', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',];
app.component.html
<div ngCombobox filterMode="auto-select"> <div #origin class="autocomplete"> <span class="search-icon material-symbols-outlined">search</span> <input aria-label="Label dropdown" placeholder="Select a country" [(ngModel)]="query" ngComboboxInput /> </div> <ng-template ngComboboxPopupContainer> <ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true" > <div class="popup"> @if (countries().length === 0) { <div class="no-results">No results found</div> } <div ngListbox> @for (country of countries(); track country) { <div ngOption [value]="country" [label]="country"> <span class="option-label">{{country}}</span> <span class="check-icon material-symbols-outlined">check</span> </div> } </div> </div> </ng-template> </ng-template></div>
app.component.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');:host { display: flex; justify-content: center; font-family: var(--inter-font);}.autocomplete { display: flex; position: relative; align-items: center;}.material-symbols-outlined { font-size: 1.25rem; pointer-events: none;}.search-icon { left: 0.75rem; position: absolute; color: var(--quaternary-contrast);}[ngComboboxInput] { width: 13rem; font-size: 1rem; border-radius: 0.25rem; padding: 0.75rem 0.5rem 0.75rem 2.5rem; color: var(--primary-contrast); outline-color: var(--hot-pink); border: 1px solid var(--quinary-contrast); background-color: var(--page-background);}[ngComboboxInput]::placeholder { color: var(--quaternary-contrast);}[ngCombobox]:has([aria-expanded='false']) .popup { display: none;}.popup { width: 100%; margin-top: 8px; padding: 0.5rem; max-height: 11rem; border-radius: 0.5rem; background-color: var(--septenary-contrast); font-size: 0.9rem;}.no-results { padding: 1rem;}[ngListbox] { gap: 2px; height: 100%; display: flex; overflow: auto; flex-direction: column;}[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 var(--hot-pink);}[ngOption][aria-selected='true'] { color: var(--hot-pink); background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);}[ngOption]:not([aria-selected='true']) .check-icon { display: none;}.option-label { flex: 1;}.check-icon { font-size: 0.9rem;}
app.component.ts
import {Combobox, ComboboxInput, ComboboxPopupContainer} from '@angular/aria/combobox';import {Listbox, Option} from '@angular/aria/listbox';import {OverlayModule} from '@angular/cdk/overlay';import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, signal, viewChild, viewChildren,} from '@angular/core';import {FormsModule} from '@angular/forms';@Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrl: 'app.component.css', imports: [ Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option, OverlayModule, FormsModule, ], 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 query string used to filter the list of countries. */ query = signal(''); /** The list of countries filtered by the query. */ countries = computed(() => ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())), ); 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); } }); }}const ALL_COUNTRIES = [ 'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Antigua and Barbuda', 'Argentina', 'Armenia', 'Australia', 'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium', 'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei', 'Bulgaria', 'Burkina Faso', 'Burundi', 'Cabo Verde', 'Cambodia', 'Cameroon', 'Canada', 'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo (Congo-Brazzaville)', 'Costa Rica', "Côte d'Ivoire", 'Croatia', 'Cuba', 'Cyprus', 'Czechia (Czech Republic)', 'Democratic Republic of the Congo', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic', 'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini (fmr. ""Swaziland"")', 'Ethiopia', 'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada', 'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Holy See', 'Honduras', 'Hungary', 'Iceland', 'India', 'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan', 'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia', 'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives', 'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova', 'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar (formerly Burma)', 'Namibia', 'Nauru', 'Nepal', 'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia', 'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine State', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru', 'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis', 'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe', 'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia', 'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka', 'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Tajikistan', 'Tanzania', 'Thailand', 'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu', 'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States of America', 'Uruguay', 'Uzbekistan', 'Vanuatu', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',];
app.component.html
<div ngCombobox filterMode="auto-select"> <div #origin class="material-autocomplete"> <span class="search-icon material-symbols-outlined">search</span> <input aria-label="Label dropdown" placeholder="Select a country" [(ngModel)]="query" ngComboboxInput /> </div> <ng-template ngComboboxPopupContainer> <ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true" > <div class="popup"> @if (countries().length === 0) { <div class="no-results">No results found</div> } <div ngListbox> @for (country of countries(); track country) { <div ngOption [value]="country" [label]="country"> <span class="option-label">{{country}}</span> <span class="check-icon material-symbols-outlined">check</span> </div> } </div> </div> </ng-template> </ng-template></div>
app.component.css
@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);}.material-autocomplete { display: flex; position: relative; align-items: center;}.material-symbols-outlined { font-size: 1.25rem; pointer-events: none;}.search-icon { left: 0.75rem; position: absolute; color: var(--quaternary-contrast);}[ngComboboxInput] { width: 13rem; font-size: 1rem; border-radius: 3rem; padding: 0.75rem 0.5rem 0.75rem 2.5rem; color: var(--primary-contrast); outline-color: var(--primary); border: 1px solid var(--quinary-contrast); background-color: var(--page-background);}[ngComboboxInput]::placeholder { color: var(--quaternary-contrast);}[ngCombobox]:focus-within [ngComboboxInput] { outline: 2px solid var(--primary); outline-offset: 2px;}[ngCombobox]:has([aria-expanded='false']) .popup { display: none;}.popup { width: 100%; margin-top: 8px; padding: 0.5rem; max-height: 11rem; border-radius: 2rem; background-color: var(--septenary-contrast); font-size: 0.9rem;}.no-results { padding: 1rem;}[ngListbox] { gap: 2px; height: 100%; display: flex; overflow: auto; flex-direction: column;}[ngOption] { display: flex; cursor: pointer; align-items: center; margin: 1px; 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']) .check-icon { display: none;}.option-label { flex: 1;}.check-icon { font-size: 0.9rem;}
app.component.ts
import {Combobox, ComboboxInput, ComboboxPopupContainer} from '@angular/aria/combobox';import {Listbox, Option} from '@angular/aria/listbox';import {OverlayModule} from '@angular/cdk/overlay';import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, signal, viewChild, viewChildren,} from '@angular/core';import {FormsModule} from '@angular/forms';@Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrl: 'app.component.css', imports: [ Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option, OverlayModule, FormsModule, ], 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 query string used to filter the list of countries. */ query = signal(''); /** The list of countries filtered by the query. */ countries = computed(() => ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())), ); 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); } }); }}const ALL_COUNTRIES = [ 'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Antigua and Barbuda', 'Argentina', 'Armenia', 'Australia', 'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium', 'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei', 'Bulgaria', 'Burkina Faso', 'Burundi', 'Cabo Verde', 'Cambodia', 'Cameroon', 'Canada', 'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo (Congo-Brazzaville)', 'Costa Rica', "Côte d'Ivoire", 'Croatia', 'Cuba', 'Cyprus', 'Czechia (Czech Republic)', 'Democratic Republic of the Congo', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic', 'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini (fmr. ""Swaziland"")', 'Ethiopia', 'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada', 'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Holy See', 'Honduras', 'Hungary', 'Iceland', 'India', 'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan', 'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia', 'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives', 'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova', 'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar (formerly Burma)', 'Namibia', 'Nauru', 'Nepal', 'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia', 'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine State', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru', 'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis', 'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe', 'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia', 'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka', 'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Tajikistan', 'Tanzania', 'Thailand', 'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu', 'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States of America', 'Uruguay', 'Uzbekistan', 'Vanuatu', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',];
app.component.html
<div ngCombobox filterMode="auto-select"> <div #origin class="retro-autocomplete"> <span class="search-icon material-symbols-outlined">search</span> <input aria-label="Label dropdown" placeholder="Select a country" [(ngModel)]="query" ngComboboxInput /> </div> <ng-template ngComboboxPopupContainer> <ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true" > <div class="popup"> @if (countries().length === 0) { <div class="no-results">No results found</div> } <div ngListbox> @for (country of countries(); track country) { <div ngOption [value]="country" [label]="country"> <span class="option-label">{{country}}</span> <span class="check-icon material-symbols-outlined">check</span> </div> } </div> </div> </ng-template> </ng-template></div>
app.component.css
@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.6rem; font-family: 'Press Start 2P'; --retro-button-color: #fff; --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --retro-flat-shadow: 4px 0px 0px 0px var(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 0px 0px 0px 0px var(--gray-700);}.retro-autocomplete { display: flex; position: relative; align-items: center;}.material-symbols-outlined { font-size: 1.25rem; pointer-events: none;}.search-icon { left: 0.75rem; position: absolute; color: #000; z-index: 1;}[ngComboboxInput] { width: 15rem; font-size: 0.6rem; border-radius: 0; font-family: 'Press Start 2P'; word-spacing: -5px; padding: 0.75rem 0.5rem 0.75rem 2.5rem; color: #000; border: none; box-shadow: var(--retro-flat-shadow); background-color: var(--retro-button-color);}[ngComboboxInput]::placeholder { color: #000; opacity: 0.7;}[ngComboboxInput]:focus { outline: none; transform: translate(1px, 1px); box-shadow: var(--retro-pressed-shadow);}[ngCombobox]:has([aria-expanded='false']) .popup { display: none;}.popup { width: 100%; margin-top: 20px; padding: 0.5rem; max-height: 11rem; border-radius: 0; background-color: var(--septenary-contrast); box-shadow: var(--retro-flat-shadow);}.no-results { padding: 1rem;}[ngListbox] { gap: 2px; height: 100%; display: flex; overflow: auto; flex-direction: column;}[ngOption] { display: flex; cursor: pointer; align-items: center; margin: 1px; padding: 0 1rem; min-height: 2.25rem; border-radius: 0;}[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);}[ngOption]:not([aria-selected='true']) .check-icon { display: none;}.option-label { flex: 1;}.check-icon { font-size: 0.9rem;}
Usage
Combobox is the primitive directive that coordinates a text input with a popup. It provides the foundation for autocomplete, select, and multiselect patterns. Consider using combobox directly when:
- Building custom autocomplete patterns - Creating specialized filtering or suggestion behavior
- Creating custom selection components - Developing dropdowns with unique requirements
- Coordinating input with popup - Pairing text input with listbox, tree, or dialog content
- Implementing specific filter modes - Using manual, auto-select, or highlight behaviors
Use documented patterns instead when:
- Standard autocomplete with filtering is needed - See the Autocomplete pattern for ready-to-use examples
- Single-selection dropdowns are needed - See the Select pattern for complete dropdown implementation
- Multiple-selection dropdowns are needed - See the Multiselect pattern for multi-select with compact display
Note: The Autocomplete, Select, and Multiselect guides show documented patterns that combine this directive with Listbox for specific use cases.
Features
Angular's combobox provides a fully accessible input-popup coordination system with:
- Text Input with Popup - Coordinates input field with popup content
- Three Filter Modes - Manual, auto-select, or highlight behaviors
- Keyboard Navigation - Arrow keys, Enter, Escape handling
- Screen Reader Support - Built-in ARIA attributes including role="combobox" and aria-expanded
- Popup Management - Automatic show/hide based on user interaction
- Signal-Based Reactivity - Reactive state management using Angular signals
Examples
Autocomplete
An accessible input field that filters and suggests options as users type, helping them find and select values from a list.
app.component.ts
import {Combobox, ComboboxInput, ComboboxPopupContainer} from '@angular/aria/combobox';import {Listbox, Option} from '@angular/aria/listbox';import {OverlayModule} from '@angular/cdk/overlay';import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, signal, viewChild, viewChildren,} from '@angular/core';import {FormsModule} from '@angular/forms';@Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrl: 'app.component.css', imports: [ Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option, OverlayModule, FormsModule, ], 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 query string used to filter the list of countries. */ query = signal(''); /** The list of countries filtered by the query. */ countries = computed(() => ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())), ); 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); } }); }}const ALL_COUNTRIES = [ 'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Antigua and Barbuda', 'Argentina', 'Armenia', 'Australia', 'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium', 'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei', 'Bulgaria', 'Burkina Faso', 'Burundi', 'Cabo Verde', 'Cambodia', 'Cameroon', 'Canada', 'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo (Congo-Brazzaville)', 'Costa Rica', "Côte d'Ivoire", 'Croatia', 'Cuba', 'Cyprus', 'Czechia (Czech Republic)', 'Democratic Republic of the Congo', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic', 'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini (fmr. ""Swaziland"")', 'Ethiopia', 'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada', 'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Holy See', 'Honduras', 'Hungary', 'Iceland', 'India', 'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan', 'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia', 'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives', 'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova', 'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar (formerly Burma)', 'Namibia', 'Nauru', 'Nepal', 'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia', 'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine State', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru', 'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis', 'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe', 'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia', 'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka', 'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Tajikistan', 'Tanzania', 'Thailand', 'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu', 'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States of America', 'Uruguay', 'Uzbekistan', 'Vanuatu', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',];
app.component.html
<div ngCombobox filterMode="auto-select"> <div #origin class="autocomplete"> <span class="search-icon material-symbols-outlined">search</span> <input aria-label="Label dropdown" placeholder="Select a country" [(ngModel)]="query" ngComboboxInput /> </div> <ng-template ngComboboxPopupContainer> <ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true" > <div class="popup"> @if (countries().length === 0) { <div class="no-results">No results found</div> } <div ngListbox> @for (country of countries(); track country) { <div ngOption [value]="country" [label]="country"> <span class="option-label">{{country}}</span> <span class="check-icon material-symbols-outlined">check</span> </div> } </div> </div> </ng-template> </ng-template></div>
app.component.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');:host { display: flex; justify-content: center; font-family: var(--inter-font);}.autocomplete { display: flex; position: relative; align-items: center;}.material-symbols-outlined { font-size: 1.25rem; pointer-events: none;}.search-icon { left: 0.75rem; position: absolute; color: var(--quaternary-contrast);}[ngComboboxInput] { width: 13rem; font-size: 1rem; border-radius: 0.25rem; padding: 0.75rem 0.5rem 0.75rem 2.5rem; color: var(--primary-contrast); outline-color: var(--hot-pink); border: 1px solid var(--quinary-contrast); background-color: var(--page-background);}[ngComboboxInput]::placeholder { color: var(--quaternary-contrast);}[ngCombobox]:has([aria-expanded='false']) .popup { display: none;}.popup { width: 100%; margin-top: 8px; padding: 0.5rem; max-height: 11rem; border-radius: 0.5rem; background-color: var(--septenary-contrast); font-size: 0.9rem;}.no-results { padding: 1rem;}[ngListbox] { gap: 2px; height: 100%; display: flex; overflow: auto; flex-direction: column;}[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 var(--hot-pink);}[ngOption][aria-selected='true'] { color: var(--hot-pink); background-color: color-mix(in srgb, var(--hot-pink) 5%, transparent);}[ngOption]:not([aria-selected='true']) .check-icon { display: none;}.option-label { flex: 1;}.check-icon { font-size: 0.9rem;}
app.component.ts
import {Combobox, ComboboxInput, ComboboxPopupContainer} from '@angular/aria/combobox';import {Listbox, Option} from '@angular/aria/listbox';import {OverlayModule} from '@angular/cdk/overlay';import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, signal, viewChild, viewChildren,} from '@angular/core';import {FormsModule} from '@angular/forms';@Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrl: 'app.component.css', imports: [ Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option, OverlayModule, FormsModule, ], 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 query string used to filter the list of countries. */ query = signal(''); /** The list of countries filtered by the query. */ countries = computed(() => ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())), ); 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); } }); }}const ALL_COUNTRIES = [ 'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Antigua and Barbuda', 'Argentina', 'Armenia', 'Australia', 'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium', 'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei', 'Bulgaria', 'Burkina Faso', 'Burundi', 'Cabo Verde', 'Cambodia', 'Cameroon', 'Canada', 'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo (Congo-Brazzaville)', 'Costa Rica', "Côte d'Ivoire", 'Croatia', 'Cuba', 'Cyprus', 'Czechia (Czech Republic)', 'Democratic Republic of the Congo', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic', 'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini (fmr. ""Swaziland"")', 'Ethiopia', 'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada', 'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Holy See', 'Honduras', 'Hungary', 'Iceland', 'India', 'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan', 'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia', 'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives', 'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova', 'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar (formerly Burma)', 'Namibia', 'Nauru', 'Nepal', 'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia', 'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine State', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru', 'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis', 'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe', 'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia', 'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka', 'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Tajikistan', 'Tanzania', 'Thailand', 'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu', 'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States of America', 'Uruguay', 'Uzbekistan', 'Vanuatu', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',];
app.component.html
<div ngCombobox filterMode="auto-select"> <div #origin class="material-autocomplete"> <span class="search-icon material-symbols-outlined">search</span> <input aria-label="Label dropdown" placeholder="Select a country" [(ngModel)]="query" ngComboboxInput /> </div> <ng-template ngComboboxPopupContainer> <ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true" > <div class="popup"> @if (countries().length === 0) { <div class="no-results">No results found</div> } <div ngListbox> @for (country of countries(); track country) { <div ngOption [value]="country" [label]="country"> <span class="option-label">{{country}}</span> <span class="check-icon material-symbols-outlined">check</span> </div> } </div> </div> </ng-template> </ng-template></div>
app.component.css
@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);}.material-autocomplete { display: flex; position: relative; align-items: center;}.material-symbols-outlined { font-size: 1.25rem; pointer-events: none;}.search-icon { left: 0.75rem; position: absolute; color: var(--quaternary-contrast);}[ngComboboxInput] { width: 13rem; font-size: 1rem; border-radius: 3rem; padding: 0.75rem 0.5rem 0.75rem 2.5rem; color: var(--primary-contrast); outline-color: var(--primary); border: 1px solid var(--quinary-contrast); background-color: var(--page-background);}[ngComboboxInput]::placeholder { color: var(--quaternary-contrast);}[ngCombobox]:focus-within [ngComboboxInput] { outline: 2px solid var(--primary); outline-offset: 2px;}[ngCombobox]:has([aria-expanded='false']) .popup { display: none;}.popup { width: 100%; margin-top: 8px; padding: 0.5rem; max-height: 11rem; border-radius: 2rem; background-color: var(--septenary-contrast); font-size: 0.9rem;}.no-results { padding: 1rem;}[ngListbox] { gap: 2px; height: 100%; display: flex; overflow: auto; flex-direction: column;}[ngOption] { display: flex; cursor: pointer; align-items: center; margin: 1px; 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']) .check-icon { display: none;}.option-label { flex: 1;}.check-icon { font-size: 0.9rem;}
app.component.ts
import {Combobox, ComboboxInput, ComboboxPopupContainer} from '@angular/aria/combobox';import {Listbox, Option} from '@angular/aria/listbox';import {OverlayModule} from '@angular/cdk/overlay';import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, signal, viewChild, viewChildren,} from '@angular/core';import {FormsModule} from '@angular/forms';@Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrl: 'app.component.css', imports: [ Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option, OverlayModule, FormsModule, ], 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 query string used to filter the list of countries. */ query = signal(''); /** The list of countries filtered by the query. */ countries = computed(() => ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.query().toLowerCase())), ); 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); } }); }}const ALL_COUNTRIES = [ 'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Antigua and Barbuda', 'Argentina', 'Armenia', 'Australia', 'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium', 'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei', 'Bulgaria', 'Burkina Faso', 'Burundi', 'Cabo Verde', 'Cambodia', 'Cameroon', 'Canada', 'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo (Congo-Brazzaville)', 'Costa Rica', "Côte d'Ivoire", 'Croatia', 'Cuba', 'Cyprus', 'Czechia (Czech Republic)', 'Democratic Republic of the Congo', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic', 'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini (fmr. ""Swaziland"")', 'Ethiopia', 'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada', 'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Holy See', 'Honduras', 'Hungary', 'Iceland', 'India', 'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan', 'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia', 'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives', 'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova', 'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar (formerly Burma)', 'Namibia', 'Nauru', 'Nepal', 'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia', 'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine State', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru', 'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis', 'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe', 'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia', 'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka', 'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Tajikistan', 'Tanzania', 'Thailand', 'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu', 'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States of America', 'Uruguay', 'Uzbekistan', 'Vanuatu', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',];
app.component.html
<div ngCombobox filterMode="auto-select"> <div #origin class="retro-autocomplete"> <span class="search-icon material-symbols-outlined">search</span> <input aria-label="Label dropdown" placeholder="Select a country" [(ngModel)]="query" ngComboboxInput /> </div> <ng-template ngComboboxPopupContainer> <ng-template [cdkConnectedOverlay]="{origin, usePopover: 'inline', matchWidth: true}" [cdkConnectedOverlayOpen]="true" > <div class="popup"> @if (countries().length === 0) { <div class="no-results">No results found</div> } <div ngListbox> @for (country of countries(); track country) { <div ngOption [value]="country" [label]="country"> <span class="option-label">{{country}}</span> <span class="check-icon material-symbols-outlined">check</span> </div> } </div> </div> </ng-template> </ng-template></div>
app.component.css
@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.6rem; font-family: 'Press Start 2P'; --retro-button-color: #fff; --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --retro-flat-shadow: 4px 0px 0px 0px var(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 0px 0px 0px 0px var(--gray-700);}.retro-autocomplete { display: flex; position: relative; align-items: center;}.material-symbols-outlined { font-size: 1.25rem; pointer-events: none;}.search-icon { left: 0.75rem; position: absolute; color: #000; z-index: 1;}[ngComboboxInput] { width: 15rem; font-size: 0.6rem; border-radius: 0; font-family: 'Press Start 2P'; word-spacing: -5px; padding: 0.75rem 0.5rem 0.75rem 2.5rem; color: #000; border: none; box-shadow: var(--retro-flat-shadow); background-color: var(--retro-button-color);}[ngComboboxInput]::placeholder { color: #000; opacity: 0.7;}[ngComboboxInput]:focus { outline: none; transform: translate(1px, 1px); box-shadow: var(--retro-pressed-shadow);}[ngCombobox]:has([aria-expanded='false']) .popup { display: none;}.popup { width: 100%; margin-top: 20px; padding: 0.5rem; max-height: 11rem; border-radius: 0; background-color: var(--septenary-contrast); box-shadow: var(--retro-flat-shadow);}.no-results { padding: 1rem;}[ngListbox] { gap: 2px; height: 100%; display: flex; overflow: auto; flex-direction: column;}[ngOption] { display: flex; cursor: pointer; align-items: center; margin: 1px; padding: 0 1rem; min-height: 2.25rem; border-radius: 0;}[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);}[ngOption]:not([aria-selected='true']) .check-icon { display: none;}.option-label { flex: 1;}.check-icon { font-size: 0.9rem;}
The filterMode="manual" setting gives complete control over filtering and selection. The input updates a signal that filters the options list. Users navigate with arrow keys and select with Enter or click. This mode provides the most flexibility for custom filtering logic. See the Autocomplete guide for complete filtering patterns and examples.
Readonly mode
A pattern that combines a readonly combobox with listbox to create single-selection dropdowns with keyboard navigation and screen reader support.
app.ts
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() || []; return values.length ? values[0] : 'Select a label'; }); /** 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); } }); }}
app.html
<div ngCombobox readonly> <div #origin class="select"> <span class="combobox-label"> <span class="selected-label-icon material-symbols-outlined">{{ 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">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> @for (label of labels; track label.value) { <div ngOption [value]="label.value" [label]="label.value"> <span class="example-option-icon material-symbols-outlined">{{label.icon}}</span> <span class="example-option-text">{{label.value}}</span> <span class="example-option-check material-symbols-outlined">check</span> </div> } </div> </div> </ng-template> </ng-template></div>
app.css
@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 3rem; 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;}
app.ts
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() || []; return values.length ? values[0] : 'Select a label'; }); /** 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); } }); }}
app.html
<div ngCombobox class="material-select" readonly> <div #origin class="select"> <span class="combobox-label"> <span class="selected-label-icon material-symbols-outlined">{{ 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">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> @for (label of labels; track label.value) { <div ngOption [value]="label.value" [label]="label.value"> <span class="example-option-icon material-symbols-outlined">{{label.icon}}</span> <span class="example-option-text">{{label.value}}</span> <span class="example-option-check material-symbols-outlined">check</span> </div> } </div> </div> </ng-template> </ng-template></div>
app.css
@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 3rem;}[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;}
app.ts
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() || []; return values.length ? values[0] : 'Select a label'; }); /** 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); } }); }}
app.html
<div ngCombobox class="retro-select" readonly> <div #origin class="select"> <span class="combobox-label"> <span class="selected-label-icon material-symbols-outlined">{{ 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">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> @for (label of labels; track label.value) { <div ngOption [value]="label.value" [label]="label.value"> <span class="example-option-icon material-symbols-outlined">{{label.icon}}</span> <span class="example-option-text">{{label.value}}</span> <span class="example-option-check material-symbols-outlined">check</span> </div> } </div> </div> </ng-template> </ng-template></div>
app.css
@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(--gray-1000)); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --retro-flat-shadow: 4px 0px 0px 0px var(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 8px 8px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 0px 0px 0px 0px var(--gray-700);}.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 4rem; 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 readonly attribute prevents typing in the input field. The popup opens on click or arrow keys. Users navigate options with keyboard and select with Enter or click.
This configuration provides the foundation for the Select and Multiselect patterns. See those guides for complete dropdown implementations with triggers and overlay positioning.
Dialog popup
Popups sometimes need modal behavior with a backdrop and focus trap. The combobox dialog directive provides this pattern for specialized use cases.
app.ts
import { Combobox, ComboboxDialog, ComboboxInput, ComboboxPopupContainer,} from '@angular/aria/combobox';import {Listbox, Option} from '@angular/aria/listbox';import {afterRenderEffect, Component, computed, signal, untracked, viewChild} from '@angular/core';import {FormsModule} from '@angular/forms';@Component({ selector: 'app-root', templateUrl: './app.html', styleUrl: './app.css', imports: [ ComboboxDialog, Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option, FormsModule, ],})export class App { dialog = viewChild(ComboboxDialog); listbox = viewChild<Listbox<string>>(Listbox); combobox = viewChild<Combobox<string>>(Combobox); value = signal(''); searchString = signal(''); options = computed(() => ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.searchString().toLowerCase()), ), ); selectedCountries = signal<string[]>([]); constructor() { afterRenderEffect(() => { if (this.dialog() && this.combobox()?.expanded()) { untracked(() => this.listbox()?.gotoFirst()); this.positionDialog(); } }); afterRenderEffect(() => { if (this.selectedCountries().length > 0) { untracked(() => this.dialog()?.close()); this.value.set(this.selectedCountries()[0]); this.searchString.set(''); } }); afterRenderEffect(() => this.listbox()?.scrollActiveItemIntoView()); } // TODO(wagnermaciel): Switch to using the CDK for positioning. positionDialog() { const dialog = this.dialog()!; const combobox = this.combobox()!; const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); const scrollY = window.scrollY; if (comboboxRect) { dialog.element.style.width = `${comboboxRect.width}px`; dialog.element.style.top = `${comboboxRect.bottom + scrollY + 4}px`; dialog.element.style.left = `${comboboxRect.left - 1}px`; } }}const ALL_COUNTRIES = [ 'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Antigua and Barbuda', 'Argentina', 'Armenia', 'Australia', 'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium', 'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei', 'Bulgaria', 'Burkina Faso', 'Burundi', 'Cabo Verde', 'Cambodia', 'Cameroon', 'Canada', 'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo (Congo-Brazzaville)', 'Costa Rica', "Côte d'Ivoire", 'Croatia', 'Cuba', 'Cyprus', 'Czechia (Czech Republic)', 'Democratic Republic of the Congo', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic', 'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini (fmr. ""Swaziland"")', 'Ethiopia', 'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada', 'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Holy See', 'Honduras', 'Hungary', 'Iceland', 'India', 'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan', 'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia', 'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives', 'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova', 'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar (formerly Burma)', 'Namibia', 'Nauru', 'Nepal', 'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia', 'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine State', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru', 'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis', 'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe', 'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia', 'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka', 'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Tajikistan', 'Tanzania', 'Thailand', 'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu', 'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States of America', 'Uruguay', 'Uzbekistan', 'Vanuatu', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',];
app.html
<div ngCombobox #combobox="ngCombobox" [readonly]="true"> <div class="combobox-input-container"> <input ngComboboxInput placeholder="Select a country..." [value]="value()" /> <span class="material-symbols-outlined icon arrow-icon">arrow_drop_down</span> </div> <ng-template ngComboboxPopupContainer> <dialog ngComboboxDialog class="dialog"> <div ngCombobox #combobox="ngCombobox" filterMode="manual" [alwaysExpanded]="true"> <div class="combobox-input-container"> <span class="material-symbols-outlined icon search-icon">search</span> <input ngComboboxInput class="combobox-input" placeholder="Search..." [(value)]="searchString" /> </div> <ng-template ngComboboxPopupContainer> @if (options().length === 0) { <div class="no-results">No results found</div> } <div ngListbox [(values)]="selectedCountries"> @for (option of options(); track option) { <div ngOption [value]="option" [label]="option"> <span class="option-label">{{option}}</span> <span class="material-symbols-outlined icon check-icon">check</span> </div> } </div> </ng-template> </div> </dialog> </ng-template></div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');:host { display: flex; justify-content: center; font-family: var(--inter-font); --border-color: color-mix(in srgb, var(--full-contrast) 20%, var(--page-background));}[ngCombobox] { position: relative; width: 100%; display: flex; flex-direction: column; border: 1px solid var(--border-color); border-radius: 0.25rem;}[ngCombobox]:has([readonly='true']) { width: 15rem;}.combobox-input-container { display: flex; position: relative; align-items: center; border-radius: 0.25rem;}[ngComboboxInput] { border-radius: 0.25rem;}[ngComboboxInput][readonly='true'] { cursor: pointer; padding: 0.7rem 1rem;}[ngCombobox]:focus-within [ngComboboxInput] { outline: 1.5px solid var(--vivid-pink); box-shadow: 0 0 0 4px color-mix(in srgb, var(--vivid-pink) 25%, transparent);}.icon { width: 24px; height: 24px; font-size: 20px; display: grid; place-items: center; pointer-events: none;}.search-icon { padding: 0 0.5rem; position: absolute; opacity: 0.8;}.arrow-icon { padding: 0 0.5rem; position: absolute; right: 0; opacity: 0.8; transition: transform 0.2s ease;}[ngComboboxInput][aria-expanded='true'] + .arrow-icon { transform: rotate(180deg);}[ngComboboxInput] { width: 100%; border: none; outline: none; font-size: 1rem; padding: 0.7rem 1rem 0.7rem 2.5rem; background-color: var(--mat-sys-surface);}.popover { margin: 0; padding: 0; border: 1px solid var(--border-color); border-radius: 0.25rem; background-color: var(--mat-sys-surface);}[ngListbox] { gap: 2px; max-height: 200px; display: flex; overflow: auto; flex-direction: column;}[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 var(--vivid-pink);}[ngOption][aria-selected='true'] { color: var(--vivid-pink); background-color: color-mix(in srgb, var(--vivid-pink) 5%, transparent);}[ngOption]:not([aria-selected='true']) .check-icon { display: none;}.option-label { flex: 1;}.check-icon { font-size: 0.9rem;}.dialog { position: absolute; left: auto; right: auto; top: auto; bottom: auto; padding: 0; border: 1px solid var(--border-color); border-radius: 0.25rem;}.dialog .combobox-input-container { border-radius: 0;}.dialog [ngCombobox],.dialog .combobox-input-container { border: none;}.dialog [ngComboboxInput] { border-bottom-left-radius: 0; border-bottom-right-radius: 0;}.dialog [ngCombobox]:focus-within [ngComboboxInput] { outline: none; box-shadow: none;}.dialog .combobox-input-container { border-bottom: 1px solid var(--border-color);}.dialog::backdrop { opacity: 0;}.no-results { padding: 1rem;}
app.ts
import { Combobox, ComboboxDialog, ComboboxInput, ComboboxPopupContainer,} from '@angular/aria/combobox';import {Listbox, Option} from '@angular/aria/listbox';import {afterRenderEffect, Component, computed, signal, untracked, viewChild} from '@angular/core';import {FormsModule} from '@angular/forms';@Component({ selector: 'app-root', templateUrl: './app.html', styleUrl: './app.css', imports: [ ComboboxDialog, Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option, FormsModule, ],})export class App { dialog = viewChild(ComboboxDialog); listbox = viewChild<Listbox<string>>(Listbox); combobox = viewChild<Combobox<string>>(Combobox); value = signal(''); searchString = signal(''); options = computed(() => ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.searchString().toLowerCase()), ), ); selectedCountries = signal<string[]>([]); constructor() { afterRenderEffect(() => { if (this.dialog() && this.combobox()?.expanded()) { untracked(() => this.listbox()?.gotoFirst()); this.positionDialog(); } }); afterRenderEffect(() => { if (this.selectedCountries().length > 0) { untracked(() => this.dialog()?.close()); this.value.set(this.selectedCountries()[0]); this.searchString.set(''); } }); afterRenderEffect(() => this.listbox()?.scrollActiveItemIntoView()); } // TODO(wagnermaciel): Switch to using the CDK for positioning. positionDialog() { const dialog = this.dialog()!; const combobox = this.combobox()!; const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); const scrollY = window.scrollY; if (comboboxRect) { dialog.element.style.width = `${comboboxRect.width}px`; dialog.element.style.top = `${comboboxRect.bottom + scrollY + 4}px`; dialog.element.style.left = `${comboboxRect.left - 1}px`; } }}const ALL_COUNTRIES = [ 'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Antigua and Barbuda', 'Argentina', 'Armenia', 'Australia', 'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium', 'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei', 'Bulgaria', 'Burkina Faso', 'Burundi', 'Cabo Verde', 'Cambodia', 'Cameroon', 'Canada', 'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo (Congo-Brazzaville)', 'Costa Rica', "Côte d'Ivoire", 'Croatia', 'Cuba', 'Cyprus', 'Czechia (Czech Republic)', 'Democratic Republic of the Congo', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic', 'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini (fmr. ""Swaziland"")', 'Ethiopia', 'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada', 'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Holy See', 'Honduras', 'Hungary', 'Iceland', 'India', 'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan', 'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia', 'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives', 'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova', 'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar (formerly Burma)', 'Namibia', 'Nauru', 'Nepal', 'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia', 'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine State', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru', 'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis', 'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe', 'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia', 'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka', 'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Tajikistan', 'Tanzania', 'Thailand', 'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu', 'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States of America', 'Uruguay', 'Uzbekistan', 'Vanuatu', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',];
app.html
<div ngCombobox #combobox="ngCombobox" [readonly]="true" class="material-combobox"> <div class="combobox-input-container"> <input ngComboboxInput placeholder="Select a country..." [value]="value()" /> <span class="material-symbols-outlined icon arrow-icon">arrow_drop_down</span> </div> <ng-template ngComboboxPopupContainer> <dialog ngComboboxDialog class="dialog"> <div ngCombobox #combobox="ngCombobox" filterMode="manual" [alwaysExpanded]="true"> <div class="combobox-input-container"> <span class="material-symbols-outlined icon search-icon">search</span> <input ngComboboxInput class="combobox-input" placeholder="Search..." [(value)]="searchString" /> </div> <ng-template ngComboboxPopupContainer> @if (options().length === 0) { <div class="no-results">No results found</div> } <div ngListbox [(values)]="selectedCountries"> @for (option of options(); track option) { <div ngOption [value]="option" [label]="option"> <span class="option-label">{{option}}</span> <span class="material-symbols-outlined icon check-icon">check</span> </div> } </div> </ng-template> </div> </dialog> </ng-template></div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');:host { display: flex; justify-content: center; font-family: var(--inter-font); --border-color: color-mix(in srgb, var(--full-contrast) 20%, var(--page-background));}[ngCombobox] { position: relative; width: 100%; display: flex; flex-direction: column; border: 1px solid var(--border-color); border-radius: 1rem;}[ngCombobox]:has([readonly='true']) { width: 15rem;}.combobox-input-container { display: flex; position: relative; align-items: center; border-radius: 1rem;}[ngComboboxInput] { border-radius: 1rem;}[ngComboboxInput][readonly='true'] { cursor: pointer; padding: 0.7rem 1rem;}[ngCombobox]:focus-within [ngComboboxInput] { outline: 1.5px solid var(--vivid-pink); box-shadow: 0 0 0 4px color-mix(in srgb, var(--vivid-pink) 25%, transparent);}.icon { width: 24px; height: 24px; font-size: 20px; display: grid; place-items: center; pointer-events: none;}.search-icon { padding: 0 0.5rem; position: absolute; opacity: 0.8;}.arrow-icon { padding: 0 0.5rem; position: absolute; right: 0; opacity: 0.8; transition: transform 0.2s ease;}[ngComboboxInput][aria-expanded='true'] + .arrow-icon { transform: rotate(180deg);}[ngComboboxInput] { width: 100%; border: none; outline: none; font-size: 1rem; padding: 0.7rem 1rem 0.7rem 2.5rem; background-color: var(--mat-sys-surface);}[ngListbox] { gap: 2px; max-height: 10rem; display: flex; overflow: auto; flex-direction: column;}[ngOption] { display: flex; cursor: pointer; align-items: center; margin: 1px; padding: 1rem; min-height: 1rem; border-radius: 1rem;}[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']) .check-icon { display: none;}.option-label { flex: 1;}.check-icon { font-size: 0.9rem;}.dialog { padding: none; position: absolute; left: auto; right: auto; top: auto; bottom: auto; border: 1px solid var(--border-color); border-radius: 1rem;}.dialog .combobox-input-container { border-radius: 0;}.dialog [ngCombobox],.dialog .combobox-input-container { border: none;}.dialog [ngComboboxInput] { border-bottom-left-radius: 0; border-bottom-right-radius: 0;}.dialog [ngCombobox]:focus-within [ngComboboxInput] { outline: none; box-shadow: none;}.dialog::backdrop { opacity: 0;}.no-results { padding: 1rem;}
app.ts
import { Combobox, ComboboxDialog, ComboboxInput, ComboboxPopupContainer,} from '@angular/aria/combobox';import {Listbox, Option} from '@angular/aria/listbox';import {afterRenderEffect, Component, computed, signal, untracked, viewChild} from '@angular/core';import {FormsModule} from '@angular/forms';@Component({ selector: 'app-root', templateUrl: './app.html', styleUrl: './app.css', imports: [ ComboboxDialog, Combobox, ComboboxInput, ComboboxPopupContainer, Listbox, Option, FormsModule, ],})export class App { dialog = viewChild(ComboboxDialog); listbox = viewChild<Listbox<string>>(Listbox); combobox = viewChild<Combobox<string>>(Combobox); value = signal(''); searchString = signal(''); options = computed(() => ALL_COUNTRIES.filter((country) => country.toLowerCase().startsWith(this.searchString().toLowerCase()), ), ); selectedCountries = signal<string[]>([]); constructor() { afterRenderEffect(() => { if (this.dialog() && this.combobox()?.expanded()) { untracked(() => this.listbox()?.gotoFirst()); this.positionDialog(); } }); afterRenderEffect(() => { if (this.selectedCountries().length > 0) { untracked(() => this.dialog()?.close()); this.value.set(this.selectedCountries()[0]); this.searchString.set(''); } }); afterRenderEffect(() => this.listbox()?.scrollActiveItemIntoView()); } // TODO(wagnermaciel): Switch to using the CDK for positioning. positionDialog() { const dialog = this.dialog()!; const combobox = this.combobox()!; const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); const scrollY = window.scrollY; if (comboboxRect) { dialog.element.style.width = `${comboboxRect.width}px`; dialog.element.style.top = `${comboboxRect.bottom + scrollY + 4}px`; dialog.element.style.left = `${comboboxRect.left - 1}px`; } }}const ALL_COUNTRIES = [ 'Afghanistan', 'Albania', 'Algeria', 'Andorra', 'Angola', 'Antigua and Barbuda', 'Argentina', 'Armenia', 'Australia', 'Austria', 'Azerbaijan', 'Bahamas', 'Bahrain', 'Bangladesh', 'Barbados', 'Belarus', 'Belgium', 'Belize', 'Benin', 'Bhutan', 'Bolivia', 'Bosnia and Herzegovina', 'Botswana', 'Brazil', 'Brunei', 'Bulgaria', 'Burkina Faso', 'Burundi', 'Cabo Verde', 'Cambodia', 'Cameroon', 'Canada', 'Central African Republic', 'Chad', 'Chile', 'China', 'Colombia', 'Comoros', 'Congo (Congo-Brazzaville)', 'Costa Rica', "Côte d'Ivoire", 'Croatia', 'Cuba', 'Cyprus', 'Czechia (Czech Republic)', 'Democratic Republic of the Congo', 'Denmark', 'Djibouti', 'Dominica', 'Dominican Republic', 'Ecuador', 'Egypt', 'El Salvador', 'Equatorial Guinea', 'Eritrea', 'Estonia', 'Eswatini (fmr. ""Swaziland"")', 'Ethiopia', 'Fiji', 'Finland', 'France', 'Gabon', 'Gambia', 'Georgia', 'Germany', 'Ghana', 'Greece', 'Grenada', 'Guatemala', 'Guinea', 'Guinea-Bissau', 'Guyana', 'Haiti', 'Holy See', 'Honduras', 'Hungary', 'Iceland', 'India', 'Indonesia', 'Iran', 'Iraq', 'Ireland', 'Israel', 'Italy', 'Jamaica', 'Japan', 'Jordan', 'Kazakhstan', 'Kenya', 'Kiribati', 'Kuwait', 'Kyrgyzstan', 'Laos', 'Latvia', 'Lebanon', 'Lesotho', 'Liberia', 'Libya', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Madagascar', 'Malawi', 'Malaysia', 'Maldives', 'Mali', 'Malta', 'Marshall Islands', 'Mauritania', 'Mauritius', 'Mexico', 'Micronesia', 'Moldova', 'Monaco', 'Mongolia', 'Montenegro', 'Morocco', 'Mozambique', 'Myanmar (formerly Burma)', 'Namibia', 'Nauru', 'Nepal', 'Netherlands', 'New Zealand', 'Nicaragua', 'Niger', 'Nigeria', 'North Korea', 'North Macedonia', 'Norway', 'Oman', 'Pakistan', 'Palau', 'Palestine State', 'Panama', 'Papua New Guinea', 'Paraguay', 'Peru', 'Philippines', 'Poland', 'Portugal', 'Qatar', 'Romania', 'Russia', 'Rwanda', 'Saint Kitts and Nevis', 'Saint Lucia', 'Saint Vincent and the Grenadines', 'Samoa', 'San Marino', 'Sao Tome and Principe', 'Saudi Arabia', 'Senegal', 'Serbia', 'Seychelles', 'Sierra Leone', 'Singapore', 'Slovakia', 'Slovenia', 'Solomon Islands', 'Somalia', 'South Africa', 'South Korea', 'South Sudan', 'Spain', 'Sri Lanka', 'Sudan', 'Suriname', 'Sweden', 'Switzerland', 'Syria', 'Tajikistan', 'Tanzania', 'Thailand', 'Timor-Leste', 'Togo', 'Tonga', 'Trinidad and Tobago', 'Tunisia', 'Turkey', 'Turkmenistan', 'Tuvalu', 'Uganda', 'Ukraine', 'United Arab Emirates', 'United Kingdom', 'United States of America', 'Uruguay', 'Uzbekistan', 'Vanuatu', 'Venezuela', 'Vietnam', 'Yemen', 'Zambia', 'Zimbabwe',];
app.html
<div ngCombobox #combobox="ngCombobox" [readonly]="true" class="retro-combobox"> <div class="combobox-input-container"> <input ngComboboxInput placeholder="Select a country..." [value]="value()" /> <span class="material-symbols-outlined icon arrow-icon">arrow_drop_down</span> </div> <ng-template ngComboboxPopupContainer> <dialog ngComboboxDialog class="dialog"> <div ngCombobox #combobox="ngCombobox" filterMode="manual" [alwaysExpanded]="true"> <div class="combobox-input-container"> <span class="material-symbols-outlined icon search-icon">search</span> <input ngComboboxInput class="combobox-input" placeholder="Search..." [(value)]="searchString" /> </div> <ng-template ngComboboxPopupContainer> <div ngListbox [(values)]="selectedCountries"> @for (option of options(); track option) { <div ngOption [value]="option" [label]="option"> <span>{{option}}</span> <span class="material-symbols-outlined icon selected-icon">check</span> </div> } </div> </ng-template> </div> </dialog> </ng-template></div>
app.css
@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.6rem; font-family: 'Press Start 2P'; --retro-button-color: #fff; --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --retro-flat-shadow: 4px 0px 0px 0px var(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 0px 0px 0px 0px var(--gray-700);}[ngComboboxInput] { width: 15rem; font-size: 0.6rem; border-radius: 0; font-family: 'Press Start 2P'; word-spacing: -5px; padding: 0.75rem 0.5rem 0.75rem 2.5rem; color: #000; border: none;}[ngComboboxInput]::placeholder { color: #000; opacity: 0.7;}[ngCombobox]:has([readonly='true']) { width: 15rem; box-shadow: var(--retro-flat-shadow); background-color: var(--retro-button-color);}.combobox-input-container { display: flex; position: relative; align-items: center;}[ngComboboxInput][readonly='true'] { cursor: pointer; padding: 0.7rem 1rem;}[ngCombobox]:focus-within [ngComboboxInput] { outline: 1.5px solid var(--vivid-pink); box-shadow: 0 0 0 4px color-mix(in srgb, var(--vivid-pink) 25%, transparent);}.icon { width: 24px; height: 24px; font-size: 20px; display: grid; place-items: center; pointer-events: none;}.search-icon { padding: 0 0.5rem; position: absolute; opacity: 0.8;}.arrow-icon { padding: 0 0.5rem; position: absolute; right: 0; opacity: 0.8; transition: transform 0.2s ease;}[ngComboboxInput][aria-expanded='true'] + .arrow-icon { transform: rotate(180deg);}[ngListbox] { display: flex; flex-direction: column; overflow: auto; max-height: 10rem; padding: 0.5rem; gap: 4px; font-size: 0.9rem;}[ngOption] { cursor: pointer; padding: 0.3rem 1rem; display: flex; overflow: hidden; flex-shrink: 0; align-items: center; justify-content: space-between; gap: 1rem; font-size: 0.6rem;}.checkbox-blank-icon,[ngOption][aria-selected='true'] .checkbox-filled-icon { display: flex; align-items: center;}.checkbox-filled-icon,[ngOption][aria-selected='true'] .checkbox-blank-icon { display: none;}.checkbox-blank-icon { opacity: 0.6;}.selected-icon { visibility: hidden;}[ngOption][aria-selected='true'] .selected-icon { visibility: visible;}[ngOption][aria-selected='true'] { color: var(--vivid-pink); background-color: color-mix(in srgb, var(--vivid-pink) 10%, transparent);}[ngOption]:hover { background-color: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);}[ngCombobox]:focus-within [data-active='true'] { outline: 2px solid color-mix(in srgb, var(--vivid-pink) 80%, transparent);}.dialog { margin-top: 8px; position: absolute; left: auto; right: auto; top: auto; bottom: auto; padding: 0; border: none; box-shadow: var(--retro-flat-shadow);}.dialog .combobox-input-container { border-radius: 0;}.dialog [ngCombobox],.dialog .combobox-input-container { border: none;}.dialog [ngComboboxInput] { border-bottom: 4px solid #000;}.dialog [ngCombobox]:focus-within [ngComboboxInput] { outline: none; box-shadow: none;}.dialog::backdrop { opacity: 0;}
The ngComboboxDialog directive creates a modal popup using the native dialog element. This provides backdrop behavior and focus trapping. Use dialog popups when the selection interface requires modal interaction or when the popup content is complex enough to warrant full-screen focus.
APIs
Combobox Directive
The ngCombobox directive coordinates a text input with a popup.
Inputs
| Property | Type | Default | Description |
|---|---|---|---|
filterMode |
'manual' | 'auto-select' | 'highlight' |
'manual' |
Controls selection behavior |
disabled |
boolean |
false |
Disables the combobox |
readonly |
boolean |
false |
Makes combobox readonly (for Select/Multiselect) |
firstMatch |
V |
- | Value of first matching item for auto-select |
alwaysExpanded |
boolean |
false |
Keeps popup always open |
Filter Modes:
'manual'- User controls filtering and selection explicitly. The popup shows options based on your filtering logic. Users select with Enter or click. This mode provides the most flexibility.'auto-select'- Input value automatically updates to the first matching option as users type. Requires thefirstMatchinput for coordination. See the Autocomplete guide for examples.'highlight'- Highlights matching text without changing the input value. Users navigate with arrow keys and select with Enter.
Signals
| Property | Type | Description |
|---|---|---|
expanded |
Signal<boolean> |
Whether popup is currently open |
Methods
| Method | Parameters | Description |
|---|---|---|
open |
none | Opens the combobox |
close |
none | Closes the combobox |
expand |
none | Expands the combobox |
collapse |
none | Collapses the combobox |
ComboboxInput Directive
The ngComboboxInput directive connects an input element to the combobox.
Model
| Property | Type | Description |
|---|---|---|
value |
string |
Two-way bindable value using [(value)] |
The input element receives keyboard handling and ARIA attributes automatically.
ComboboxPopup Directive
The ngComboboxPopup directive (host directive) manages popup visibility and coordination. Typically used with ngComboboxPopupContainer in an ng-template or with CDK Overlay.
ComboboxPopupContainer Directive
The ngComboboxPopupContainer directive marks an ng-template as the popup content.
<ng-template ngComboboxPopupContainer> <div ngListbox>...</div></ng-template>
Used with Popover API or CDK Overlay for positioning.
ComboboxDialog Directive
The ngComboboxDialog directive creates a modal combobox popup.
<dialog ngComboboxDialog> <div ngListbox>...</div></dialog>
Use for modal popup behavior with backdrop and focus trap.
Related patterns and directives
Combobox is the primitive directive for these documented patterns:
- Autocomplete - Filtering and suggestions pattern (uses Combobox with filter modes)
- Select - Single selection dropdown pattern (uses Combobox with
readonly) - Multiselect - Multiple selection pattern (uses Combobox with
readonly+ multi-enabled Listbox)
Combobox typically combines with: