CLS Angular 17+

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.

Angular -- NgOptimizedImage with dimensions
// 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.

Angular -- Stable lifecycle hook usage
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.

Angular -- CLS-safe animation with transform
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.

Angular -- @defer with stable placeholder
<!-- 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.

Angular -- OnPush change detection
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 NgOptimizedImage with explicit width and height attributes
  • Layout-affecting logic is moved out of ngAfterViewInit into ngOnInit or constructor
  • All Angular Animations use style({ transform }) and opacity only
  • Every @defer block has a @placeholder matching the loaded component's height
  • All components use ChangeDetectionStrategy.OnPush
  • Web fonts use font-display: swap with 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.

Continue learning