Angular CDK Virtual Scrolling: Handling Dynamic Heights Without Layout Thrashing
Angular CDK virtual scrolling handles large lists, but dynamic heights introduce layout thrashing and accessibility gaps. Learn the trade-offs, a worked re-measurement example, and a verification checklist for Angular 17+.
25 Jan 2026, 08:57 UTC

The Problem: Rendering 10,000 Rows Without Freezing the Browser
You have a data grid that must display thousands of rows. A naive *ngFor creates a DOM node for every item, and the browser chokes on layout, paint, and memory. Angular CDK's cdk-virtual-scroll-viewport solves this by rendering only the visible items plus a small buffer. But the moment your rows have variable heights — think wrapped text, expandable sections, or user-generated content — the simple fixed-size strategy falls apart. This post walks through the practical decisions you face when adopting dynamic virtual scrolling in Angular 17+, including a worked example, the performance trade-offs you can't ignore, and a verification checklist you can run today.
Core APIs: Viewport, Strategy, and Buffer
The viewport (<cdk-virtual-scroll-viewport>) is a scrollable container with a fixed height (set via CSS). It requires a VirtualScrollStrategy to decide which items to render and where to position them. Two strategies ship with the CDK:
- FixedSizeVirtualScrollStrategy — you provide a constant
itemSizein pixels. Zero layout reads during scroll; ideal for uniform rows. - AutoSizeVirtualScrollStrategy — measures each item's height on first render via
getBoundingClientRect()and caches the result. Handles dynamic heights but forces synchronous layout reads during scroll.
Buffer sizing controls how many extra items render above and below the viewport. Since Angular 15 you can tune it with CSS custom properties (--cdk-virtual-scroll-viewport-buffer) or the minBufferPx/maxBufferPx inputs. The default buffer is roughly 5× the viewport height; large buffers increase DOM node count but smooth fast scrolling.
Worked Example: Dynamic Heights with Re-measurement
Below is a minimal standalone component (Angular 17+) that renders 10,000 items with varying text lengths. It uses AutoSizeVirtualScrollStrategy and exposes a button to simulate a content change that requires re-measurement. This is a reference implementation — verify it against your CDK version, as the strategy API has evolved across releases.
import { Component, ViewChild, ChangeDetectorRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling';
import { AutoSizeVirtualScrollStrategy } from '@angular/cdk/scrolling';
@Component({
selector: 'app-virtual-list',
standalone: true,
imports: [CommonModule, ScrollingModule],
template: `
<button (click)="toggleLongText()">Toggle long text</button>
<cdk-virtual-scroll-viewport #viewport class="viewport"
[scrollingStrategy]="strategy">
<div *cdkVirtualFor="let item of items" class="item">
{{ item.text }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [
`.viewport { height: 400px; border: 1px solid #ccc; }`,
`.item { padding: 8px; border-bottom: 1px solid #eee; transform: translateZ(0); }`
]
})
export class VirtualListComponent {
@ViewChild(CdkVirtualScrollViewport) viewport!: CdkVirtualScrollViewport;
strategy = new AutoSizeVirtualScrollStrategy(20, 50, 200);
items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}`, long: false }));
constructor(private cdr: ChangeDetectorRef) {}
toggleLongText() {
this.items = this.items.map(item => ({
...item,
long: !item.long,
text: item.long ? `Item ${item.id}` : `Item ${item.id} — `.repeat(20)
}));
// Critical: trigger re-measurement after content change
this.viewport.checkViewportSize();
this.cdr.detectChanges();
}
}
Key points in the example:
- The viewport has a fixed height (
400px) — without it, virtual scrolling cannot calculate the visible range. AutoSizeVirtualScrollStrategytakes bounding parameters (minimum, maximum, and default item height in pixels) to constrain initial estimates before real measurements land.transform: translateZ(0)on.itempromotes each row to its own compositor layer, mitigating 1px subpixel gaps reported in Firefox and Safari.- After mutating
items, we callviewport.checkViewportSize()to invalidate cached heights and force re-measurement. Without this, stale heights cause overlapping rows or blank gaps.
Trade-offs You Must Accept
Layout Thrashing on Low-End Devices
AutoSizeVirtualScrollStrategy reads getBoundingClientRect() synchronously during scroll. On a 10,000-item list with variable heights, each scroll event can trigger forced layouts. In the Chrome DevTools Performance panel you will see long "Recalculate Style" and "Layout" blocks. The fixed-size strategy avoids this entirely but demands uniform row heights — often unrealistic for content-heavy UIs.
Accessibility Gaps
The viewport sets aria-roledescription="list" and manages focus internally. However, screen readers only announce items currently in the DOM, so users navigating by heading or landmark will miss non-visible content. Provide a search or filter control as an alternative navigation path; do not rely solely on virtual scrolling for discoverability.
SSR and Hydration
With Angular Universal, virtual scrolling is skipped on the server and the initial HTML contains only the first buffer of items. The initial payload is smaller, improving first contentful paint. On hydration, the viewport becomes interactive and starts measuring. Ensure your data source is serializable and call checkViewportSize() after hydration if content changes.
Nested Scroll Containers Break Intersection
If the viewport lives inside a mat-sidenav or another scrollable element, the CDK's intersection calculations may target the wrong ancestor. Apply the cdkScrollable directive to the actual scrolling element, or restructure so the viewport is the primary scroll ancestor.
Verification Checklist (Run These Before Shipping)
- DOM node count: Open the DevTools Elements panel and scroll through the list. Node count should stay roughly constant (viewport height ÷ average item height × buffer factor). For a 400px viewport with 20px items and a 5× buffer, expect roughly 50–100 nodes.
- Dynamic height change: Click the "Toggle long text" button. Verify no visual gaps or overlaps appear. If they do, confirm
checkViewportSize()runs after the data mutation and change detection completes. - Accessibility audit: Run Lighthouse or axe-core. Check for focus-management and ARIA-role violations. Add a search input that filters the data source as a fallback navigation path.
- SSR output: Build with
ng build --ssrand inspect the generated HTML. You should see only the first buffer of items. Hydrate in the browser and confirm scrolling activates without layout shift. - Cross-browser layout: Test fast scrolling in Chrome, Firefox, and Safari (latest two versions). If 1px gaps appear, confirm
transform: translateZ(0)is applied to item elements.
Actionable Closing
Start with fixed-size virtual scrolling if your rows are uniform — it's simpler, faster, and avoids layout thrashing. When you need dynamic heights, adopt AutoSizeVirtualScrollStrategy but budget for the synchronous layout cost: profile on low-end devices, keep buffers modest, and always call checkViewportSize() after content mutations. Treat accessibility as a first-class requirement by shipping a search or filter alongside the virtual list. Finally, automate the verification steps in your CI pipeline (DOM count via a headless browser, accessibility via axe-core) so regressions are caught before they reach users.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.