Fix CLS in Angular
CLS in Angular 17+ apps has several framework-specific causes: images rendered without dimensions, layout mutations inside ngAfterViewInit that run after the initial paint, Angular Animations targeting layout-triggering CSS properties, @defer blocks that expand from zero height, and web font swap reflow. This guide covers the highest-impact fixes with working Angular 17+ code using standalone components, signals, and the latest directives.
Expected results
Before
0.29
CLS (Poor) -- images without dimensions, ngAfterViewInit layout changes, layout-triggering animations
After
0.04
CLS (Good) -- NgOptimizedImage dimensions, stable lifecycle, transform animations
Step-by-step fix
Use NgOptimizedImage with explicit width and height
The NgOptimizedImage directive (available as a standalone import from @angular/common since Angular 15) enforces width and height attributes at build time. Without these, the browser cannot compute the aspect ratio before the image loads, so the layout collapses to zero height and then expands when the image arrives. With explicit dimensions the browser reserves the correct space immediately, eliminating the shift entirely.
// product-card.component.ts
import { Component, input } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-product-card',
standalone: true,
imports: [NgOptimizedImage],
template: `
<!-- Bad: bare img collapses to 0x0, shifts content when loaded -->
<img [src]="product().imageUrl" [alt]="product().name" />
<!-- Good: NgOptimizedImage reserves 400x300 before fetch -->
<img
[ngSrc]="product().imageUrl"
[alt]="product().name"
width="400"
height="300"
style="width: 100%; height: auto;"
/>
<!-- For the LCP image: add priority to preload -->
<img
[ngSrc]="product().imageUrl"
[alt]="product().name"
width="800"
height="600"
priority
style="width: 100%; height: auto;"
/>
`,
})
export class ProductCardComponent {
product = input.required<{ imageUrl: string; name: string }>();
}
Avoid layout changes in ngAfterViewInit
ngAfterViewInit runs after Angular's initial render. Any DOM mutation there -- appending elements, changing dimensions, toggling visibility -- runs after the first paint and shifts already-painted content. Move layout-affecting logic to the constructor or ngOnInit wherever possible. When changes cannot be avoided in ngAfterViewInit, call ChangeDetectorRef.detectChanges() immediately after the mutation to flush it synchronously in the same frame, preventing a second paint.
import { Component, OnInit, AfterViewInit,
ChangeDetectorRef, inject, signal } from '@angular/core';
import { DataService } from './data.service';
@Component({
selector: 'app-dashboard',
standalone: true,
template: `
<section [class.expanded]="isExpanded()">
<h2>{{ title() }}</h2>
</section>
`,
})
export class DashboardComponent implements OnInit, AfterViewInit {
private cdr = inject(ChangeDetectorRef);
private dataService = inject(DataService);
title = signal('');
isExpanded = signal(false);
// Good: data fetching in ngOnInit -- no post-paint layout change
ngOnInit(): void {
this.dataService.getTitle().subscribe(t => this.title.set(t));
}
// Bad: layout mutation after initial render causes CLS
// ngAfterViewInit(): void {
// this.isExpanded.set(true); // shifts siblings after paint
// }
// If unavoidable -- flush in the same frame
ngAfterViewInit(): void {
if (this.dataService.shouldExpand()) {
this.isExpanded.set(true);
this.cdr.detectChanges(); // flush synchronously, same frame
}
}
}
Use Angular Animations with state() and style(transform)
Angular Animations that target height, margin, top, or width trigger layout recalculation on every animation frame, and the browser scores each recalculation as a layout shift contributing to CLS. Replace these with transform and opacity, which run entirely on the compositor thread without touching layout. Use trigger(), state(), transition(), and animate() from @angular/animations.
import { Component, signal } from '@angular/core';
import {
trigger, state, style, transition, animate
} from '@angular/animations';
@Component({
selector: 'app-notification',
standalone: true,
animations: [
trigger('slideIn', [
// Bad: animating margin causes layout recalculation per frame
// state('hidden', style({ marginTop: '-60px', opacity: 0 })),
// state('visible', style({ marginTop: '0', opacity: 1 })),
// Good: transform is compositor-only, zero CLS impact
state('hidden', style({ transform: 'translateY(-60px)', opacity: 0 })),
state('visible', style({ transform: 'translateY(0)', opacity: 1 })),
transition('hidden => visible', animate('300ms ease-out')),
transition('visible => hidden', animate('200ms ease-in')),
]),
],
template: `
<div [@slideIn]="visible() ? 'visible' : 'hidden'" class="notification">
Changes saved successfully
</div>
<button (click)="toggle()">Toggle</button>
`,
})
export class NotificationComponent {
visible = signal(false);
toggle(): void { this.visible.update(v => !v); }
}
Reserve space for @defer blocks
Angular 17's @defer syntax loads component bundles lazily. When the trigger fires, the component renders from nothing into the DOM. Without reserved space the surrounding content shifts. The @placeholder block provides initial content, but if it is shorter than the deferred component the shift still occurs. Use a @placeholder whose dimensions match the loaded component, or wrap the entire @defer in a container with an explicit min-height.
<!-- Bad: @defer block expands from zero, shifts content below -->
@defer (on viewport) {
<app-comments-section />
}
<!-- Good option 1: wrapper with min-height matching component -->
<div style="min-height: 480px;">
@defer (on viewport) {
<app-comments-section />
} @placeholder {
<div style="height: 480px;"></div>
}
</div>
<!-- Good option 2: skeleton that matches real content layout -->
@defer (on interaction) {
<app-data-table [rows]="rows()" />
} @placeholder {
<app-data-table-skeleton [rowCount]="10" />
} @loading (minimum 200ms) {
<app-data-table-skeleton [rowCount]="10" />
}
Apply ChangeDetectionStrategy.OnPush
The default ChangeDetectionStrategy.Default runs change detection on every browser event -- mouse moves, timers, HTTP responses -- rechecking the entire component tree. Each check can trigger QueryList and ViewChild updates that mutate the DOM, causing unexpected layout shifts. OnPush restricts change detection to cases where an input signal or observable produces a new reference, an event fires inside the component, or markForCheck() is called explicitly. This eliminates the category of spurious post-paint DOM mutations that generate CLS.
import {
Component, ChangeDetectionStrategy, input, computed
} from '@angular/core';
// Bad: Default strategy re-renders on every event, anywhere in the app
// @Component({ changeDetection: ChangeDetectionStrategy.Default })
// Good: OnPush limits re-renders to input changes and explicit triggers
@Component({
selector: 'app-product-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul>
@for (item of visibleItems(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
`,
})
export class ProductListComponent {
// Signals integrate naturally with OnPush --
// the view updates only when signal values change
products = input.required<Product[]>();
filter = input('');
visibleItems = computed(() =>
this.products().filter(p =>
p.name.toLowerCase().includes(this.filter().toLowerCase())
)
);
}
interface Product { id: number; name: string; }
Quick checklist
-
All images use
NgOptimizedImagewith explicitwidthandheightattributes -
Layout-affecting logic is moved out of
ngAfterViewInitintongOnInitor constructor -
All Angular Animations use
style({ transform })andopacityonly -
Every
@deferblock has a@placeholdermatching the loaded component's height -
All components use
ChangeDetectionStrategy.OnPush -
Web fonts use
font-display: swapwith size-adjusted fallback@font-face
Frequently asked questions
The main sources are images rendered without width and height attributes expanding as they load, layout mutations inside ngAfterViewInit running after the initial paint, Angular Animations targeting layout-triggering properties such as height or margin, @defer blocks that expand from zero height when their trigger fires, and web font swap reflow when a fallback font is replaced by the loaded web font.
Yes. ngAfterViewInit fires after Angular finishes the initial render. Any DOM mutation there -- inserting elements, changing heights, toggling visibility -- occurs after the first paint and shifts already-painted content. Move logic to the constructor or ngOnInit wherever possible. If ngAfterViewInit mutations are unavoidable, call ChangeDetectorRef.detectChanges() immediately to flush in the same task and avoid a secondary paint.
Angular Animations that interpolate height, margin, top, left, or width force the browser to recalculate layout on every animation frame. Each recalculation can be scored as a layout shift contributing to CLS. Replacing those properties with transform: translateY() or transform: scaleY() combined with opacity keeps the animation on the GPU compositor thread with zero layout impact.
Yes. When a @defer trigger fires, Angular inserts the deferred component bundle into the DOM. Without space already reserved in the layout, surrounding content shifts downward. Fix this by using the @placeholder block with content matching the loaded component's dimensions, or by wrapping the entire @defer in a container div with a min-height matching the expected content.
Open Chrome DevTools, go to the Rendering tab, and enable Layout Shift Regions. Record a performance trace -- shifts appear as colored overlays on the page. Run Lighthouse in terminal (npx lighthouse http://localhost:4200 --view) for reproducible lab scores. For real-user field data, install the web-vitals npm package and call onCLS(console.log) in main.ts.
Set up real-user monitoring using the web-vitals JavaScript library (1.5KB). Send CLS data to your analytics platform (Google Analytics 4, custom endpoint). The attribution build identifies exactly which element caused each layout shift. For Angular, also monitor CLS after route transitions, as client-side navigation can trigger additional shifts not captured in initial page load.
Related resources
Complete CLS Guide
Deep dive into CLS calculation, session windows, and cross-framework optimization strategies.
FixFix CLS in React
React-specific CLS fixes: image dimensions, useEffect stability, and CSS-in-JS SSR extraction.
FixFix CLS in Vue 3
Vue 3 fixes using dimensioned images, v-show stability, skeleton placeholders, and transform transitions.
Continue learning
Complete CLS Guide
Deep dive into CLS -- thresholds, measurement, and optimization strategies.
FixFix LCP in Angular
Related performance optimization for the same framework.
FixFix INP in Angular
Related performance optimization for the same framework.
ToolCWV Score Explainer
Enter your scores for personalized fix recommendations.