How to Make Angular Material Select Work with Virtual Scroll

George Hulpoi4 min read · a day ago

Angular Material has a powerful select component, but it does not fully support virtual scrolling. Even in version 19.1.5, users still face issues when combining the two features.

When using virtual scrolling, selected options may disappear when they scroll out of view. This happens because Angular removes items from the page to improve performance. When the selected option is removed, the select component no longer recognizes it.

In this post, I will explain why this happens and show a simple way to fix it.

Summary

Virtual scrolling helps improve performance by only keeping a small number of options in the page at a time. As you scroll, Angular removes options that are no longer visible and adds new ones.

The problem is that when a selected option is removed, the select component loses track of it. This makes it look like the selection has disappeared, even though it is still in the list.

Here is an example of code that does not work as expected:

123456789101112131415161718192021222324252627282930313233
import { Component } from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
    selector: 'app-not-working',
    template: `
        <mat-form-field>
            <mat-label>Not working</mat-label>
            <mat-select multiple>
                <cdk-virtual-scroll-viewport
                    itemSize="48"
                    minBufferPx="240"
                    maxBufferPx="240"
                    [style.height.px]="240"
                >
                    <mat-option
                        *cdkVirtualFor="let option of options"
                        [value]="option"
                    >
                        Option {{ option }}
                    </mat-option>
                </cdk-virtual-scroll-viewport>
            </mat-select>
        </mat-form-field>
    `,
    standalone: true,
    imports: [MatFormFieldModule, MatSelectModule, ScrollingModule],
})
export class NotWorkingComponent {
    options = [...Array(100)].map((value, index) => index + 1);
}

The problem happens because of how the select component keeps track of selected options. It uses something called SelectionModel, which stores selected values based on what is currently in the list.

Here is a part of the MatSelect code that shows how the selected values are retrieved:

1234567891011121314151617181920212223242526
  /** The value displayed in the trigger. */
export class MatSelect /* … */ {
    /* ... */

    get triggerValue(): string {
        if (this.empty) {
            return '';
        }

        if (this._multiple) {
            const selectedOptions = this._selectionModel.selected.map(
                (option) => option.viewValue,
            );

            if (this._isRtl()) {
                selectedOptions.reverse();
            }

            // TODO(crisbeto): delimiter should be configurable for proper localization.
            return selectedOptions.join(', ');
        }

        return this._selectionModel.selected[0].viewValue;
    }
}

Morefuther, the select component refreshes its options every time the list changes. This is handled in the following code:

123456789101112131415
export class MatSelect /* … */ {
    @ContentChildren(MatOption, { descendants: true }) options: QueryList<MatOption>;

    /* ... */

    ngAfterContentInit() {
        /* ... */

        this.options.changes.pipe(startWith(null)).subscribe(() => {
            this._resetOptions();
            this._initializeSelection();
        });
    }
}

Every time virtual scrolling updates the list, the _selectionModel is recreated. This means selected values that are no longer in view are erased, making them appear lost.

The solution is to keep the selected options in the DOM, even when they are not currently visible in the virtual scroll. This way, the select component can still recognize them as selected, even if they are off-screen.

Instead of removing the selected options from the DOM, we can hide them while keeping them in place. This ensures the select component can still track them.

Here’s the code to make it work:

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
import {
    Component,
    viewChildren,
    Signal,
    computed,
    signal,
} from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatOption, MatSelectModule } from '@angular/material/select';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { FormsModule } from '@angular/forms';

@Component({
    selector: 'app-working',
    template: `
        <mat-form-field>
            <mat-label>Working</mat-label>
            <mat-select multiple [(ngModel)]="selected">
                <cdk-virtual-scroll-viewport
                    itemSize="48"
                    minBufferPx="240"
                    maxBufferPx="240"
                    [style.height.px]="240"
                >
                    @for (option of hiddenOptions(); track option) {
                        <!-- IMPORTANT! Keep the content of 'mat-option' the same -->
                        <mat-option [value]="option" [style.display]="'none'">
                            Option {{ option }}
                        </mat-option>
                    }
                    <mat-option
                        #virtualOption
                        *cdkVirtualFor="let option of options"
                        [value]="option"
                    >
                        Option {{ option }}
                    </mat-option>
                </cdk-virtual-scroll-viewport>
            </mat-select>
        </mat-form-field>
    `,
    standalone: true,
    imports: [
        MatFormFieldModule,
        MatSelectModule,
        ScrollingModule,
        FormsModule,
    ],
})
export class WorkingComponent {
    virtualOptions = viewChildren<MatOption>('virtualOption');
    selected = signal<number[]>([]);
    options = [...Array(100)].map((value, index) => index + 1);
    hiddenOptions: Signal<number[]>;

    constructor() {
        this.hiddenOptions = computed(() => {
            // The Set data structure allows for O(1) lookup time
            const virtualOptions = new Set(
                this.virtualOptions().map((matOption) => matOption.value),
            );
            return this.selected().filter(
                (value) => !virtualOptions.has(value),
            );
        });
    }
}

What this code does:

  • It keeps the selected options in the DOM by setting their display to none. This hides them from view but still keeps them in the list.
  • The hiddenOptions computed property checks which options are selected and returns them for hiding.
  • When a selected option is not in view, it will still exist in the DOM and can be recognized by the select component.

When using virtual scrolling, there is another issue: if you close the select component and then open it again after scrolling, you may see a blank space instead of the options. This happens because virtual scrolling does not reset when you open the select again.

To fix this, we need to manually reset the virtual scroll position when the select component is closed. This ensures that the virtual scroll viewport is properly initialized and doesn't show a blank space.

Here’s how you can implement this fix:

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
import {
    Component,
    viewChildren,
    Signal,
    computed,
    signal,
    viewChild,
} from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatOption, MatSelectModule } from '@angular/material/select';
import {
    CdkVirtualScrollViewport,
    ScrollingModule,
} from '@angular/cdk/scrolling';
import { FormsModule } from '@angular/forms';

@Component({
    selector: 'app-working',
    template: `
        <mat-form-field>
            <mat-label>Working</mat-label>
            <mat-select
                multiple
                [(ngModel)]="selected"
                (openedChange)="handleOpenedChange($event)"
            >
                <cdk-virtual-scroll-viewport
                    itemSize="48"
                    minBufferPx="240"
                    maxBufferPx="240"
                    [style.height.px]="240"
                >
                    @for (option of hiddenOptions(); track option) {
                        <!-- IMPORTANT! Keep the content of 'mat-option' the same -->
                        <mat-option [value]="option" [style.display]="'none'">
                            Option {{ option }}
                        </mat-option>
                    }
                    <mat-option
                        #virtualOption
                        *cdkVirtualFor="let option of options"
                        [value]="option"
                    >
                        Option {{ option }}
                    </mat-option>
                </cdk-virtual-scroll-viewport>
            </mat-select>
        </mat-form-field>
    `,
    standalone: true,
    imports: [
        MatFormFieldModule,
        MatSelectModule,
        ScrollingModule,
        FormsModule,
    ],
})
export class WorkingComponent {
    virtualOptions = viewChildren<MatOption>('virtualOption');
    virtualScroll = viewChild(CdkVirtualScrollViewport);
    selected = signal<number[]>([]);
    options = [...Array(100)].map((value, index) => index + 1);
    hiddenOptions: Signal<number[]>;

    constructor() {
        this.hiddenOptions = computed(() => {
            // The Set data structure allows for O(1) lookup time
            const virtualOptions = new Set(
                this.virtualOptions().map((matOption) => matOption.value),
            );
            return this.selected().filter(
                (value) => !virtualOptions.has(value),
            );
        });
    }

    handleOpenedChange(isOpen: boolean): void {
        const virtualScroll = this.virtualScroll();

        if (!isOpen && virtualScroll) {
            virtualScroll.scrollToOffset(0);
            virtualScroll.checkViewportSize();
        }
    }
}

What this code does:

  • The openedChange event is used to detect when the select is opened or closed.
  • When the select is closed (isOpen is false), the scroll position is reset to the top (scrollToOffset(0)), and the virtual scroll viewport is resized to ensure it displays correctly.
  • This prevents the white empty space that can appear after scrolling.

In this post, we’ve looked at how to make the Angular Material Select component work correctly with virtual scrolling.

By default, virtual scrolling can cause selected options to disappear when they are removed from the DOM for performance reasons. We explained how this happens and provided a solution to keep selected options hidden but still in the DOM. This allows the select component to maintain the correct selection.

We also covered a bonus fix to reset the virtual scroll position when the select component is reopened. This helps avoid issues like a blank space appearing in the dropdown.

With these changes, you should have a smooth, functional experience using virtual scroll with Angular Material Select. While this fix solves the current issue, it's worth keeping an eye on future Angular updates to see if this functionality is officially supported.

You can find an open issue here: https://github.com/angular/components/issues/30559