<link rel="stylesheet" href="assets/fonts/inter.css"> Cumulative Layout Shift (CLS): The Complete Fix Guide… | Viral Patel Studio Skip to main content

Cumulative Layout Shift (CLS): The Complete Fix Guide for Angular Apps in 2025

May 28, 2025By Viral Patel

Fix Cumulative Layout Shift in your Angular app with this 2025 guide covering images, fonts, ads, dynamic content, code fixes, and a CLS checklist.

Cumulative Layout Shift (CLS): The Complete Fix Guide for Angular Apps in 2025

You go to tap a button. The page jumps. You tap something else entirely. That's Cumulative Layout Shift — the most frustrating Core Web Vital because users experience it physically, even if they have no idea what it's called. Google penalises it as a ranking signal. Your users penalise it with a back-button click. For Angular apps, CLS problems cluster around the same handful of causes: images without dimensions, fonts swapping, dynamic content pushing layout, and the classic scrollbar flicker on route change. Every one of these has a clean, testable fix. This guide walks through each cause in depth, with the exact Angular code patterns to eliminate them.

learn about Angular Development

angular development

performance

Angular SSR & Hydration 2025: The Complete Performance Guide to Server-Side Rendering


Understanding CLS: How It's Scored

CLS is not a simple on/off measurement. Google calculates it as the sum of individual layout shift scores across the entire page load session. Each shift score is the product of two fractions:

Impact fraction — how much of the viewport is affected by the shift. If an element moves and its combined before/after area covers 50% of the viewport, the impact fraction is 0.5.

Distance fraction — how far the element moved, as a fraction of the viewport height. An element moving 20% of the viewport height gives a distance fraction of 0.2.

A single large image without dimensions loading above the fold can produce a shift score of 0.1 or more on its own. CLS accumulates across all such shifts.

The thresholds:

  • Good: < 0.1
  • Needs improvement: 0.1 to 0.25
  • Poor: > 0.25

One important rule: shifts triggered within 500ms of a user interaction do not count. If a user clicks a button and content moves, that's expected behaviour and excluded from the score. Only unexpected shifts — those the user did not initiate — contribute to CLS.

How to Measure CLS in Angular Apps

Chrome DevTools — Performance tab: Record a page load and look for purple "Layout Shift" markers in the Experience row. Click each one to see which elements shifted and by how much.

Lighthouse: Run an audit in DevTools or via the CLI. The CLS score is lab data — a single simulated load. Useful for development iteration.

PageSpeed Insights: Shows CrUX field data — real user measurements from Chrome users. This is the number Google uses for ranking. It may differ significantly from lab data.

Chrome Web Vitals extension: Shows a real-time CLS badge on any page as you browse it normally.

web vitals

Console debugging during development — add this to your main.ts or a dev-only script:

new PerformanceObserver((list) => {
  list.getEntries().forEach(entry => {
    if (!entry.hadRecentInput) {
      console.log('CLS shift:', entry.value, entry.sources);
    }
  });
}).observe({ type: 'layout-shift', buffered: true });

The sources array tells you exactly which DOM element caused the shift and where it moved from and to. This is the fastest way to pinpoint CLS culprits in a local Angular dev server session.


Fix 1: Images Without Dimensions (Most Common Angular CLS Cause)

This is responsible for a majority of Angular app CLS scores above 0.1. When the browser encounters an <img> tag without width and height attributes, it cannot reserve space for it. The image renders at 0×0 pixels, the browser lays out surrounding content, then the image loads and expands — pushing everything below it down. That push is a layout shift.

NgOptimizedImage: Angular's Built-in Fix

Angular has included NgOptimizedImage since v15. It enforces best practices at the template level and provides warnings at development time when dimensions are missing.

// app.config.ts — no extra setup needed, just import in the component
// Or add globally to app.module.ts imports
import { NgOptimizedImage } from '@angular/common';

@Component({
  imports: [NgOptimizedImage],
  // ...
})
export class HeroComponent {}
<!-- ❌ No dimensions — causes CLS -->
<img src="/assets/hero.jpg" alt="Hero image">

<!-- ✅ NgOptimizedImage — enforces width+height, adds fetchpriority, generates srcset -->
<img ngSrc="/assets/hero.jpg" width="1200" height="630" alt="Hero image" priority>

<!-- ✅ For below-fold images — lazy loaded by default, no priority needed -->
<img ngSrc="/assets/card-thumbnail.jpg" width="400" height="225" alt="Article thumbnail">

What NgOptimizedImage does automatically:

  • Requires width and height — throws a dev-time error if missing
  • Adds fetchpriority="high" when priority is set (critical for LCP images)
  • Lazy-loads images below the fold by default
  • Generates a srcset so the browser fetches the right resolution
  • Warns if the rendered size differs significantly from the declared dimensions

The width and height attributes you provide should match the intrinsic dimensions of the image file, not the rendered CSS dimensions. The browser uses the ratio to reserve space, then CSS controls the actual display size.

Fluid-Width Images: aspect-ratio CSS

When an image must fill its container width (a responsive card, for example), you may not have fixed pixel dimensions to declare. Use aspect-ratio to reserve the space:

/* Reserve space before the image loads */
.card__image {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

This tells the browser: "whatever width this element gets, make its height 9/16 of that." Space is reserved immediately during layout, before the image loads. No shift.

For NgOptimizedImage with fluid layout, use the fill attribute and wrap in a positioned container:

<div style="position: relative; aspect-ratio: 16/9;">
  <img ngSrc="/assets/hero.jpg" fill alt="Hero" priority>
</div>

Fix 2: Font Loading Layout Shift (FOUT)

Flash of Unstyled Text (FOUT) is a classic CLS cause. The browser renders text with a system fallback font, then the web font loads and the text reflows to the correct metrics. Even a small difference in letter spacing or line height produces a measurable layout shift.

The Wrong Way to Load Google Fonts

<!-- ❌ Default Google Fonts — no preconnect, no display parameter -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600" rel="stylesheet">

This blocks rendering, loads the font late, and causes FOUT with no size compensation.

The Right Way

<!-- ✅ Preconnect + display=optional for zero-CLS font loading -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=optional" rel="stylesheet">

display=optional tells the browser to use the web font only if it loads within a very short window (around 100ms). If it misses that window, the fallback is used for the entire page load — no swap, no shift. The trade-off is that on first load over a slow connection, users may see the fallback font. On repeat visits, the font is cached and loads instantly.

display=swap is the middle ground: it shows the fallback, then swaps in the web font when it loads. This still causes a shift but is better than the default (which blocks rendering entirely).

Matching Fallback Font Metrics with size-adjust

For display=swap, you can dramatically reduce the visual shift by making your fallback font match the web font's metrics:

@font-face {
  font-family: 'Inter-Fallback';
  src: local('Arial');
  size-adjust: 96.7%;       /* scale Arial to match Inter's cap height */
  ascent-override: 90%;     /* match Inter's ascent */
  descent-override: 22%;    /* match Inter's descent */
}

body {
  font-family: 'Inter', 'Inter-Fallback', sans-serif;
}

With this in place, when Inter loads and replaces Arial, the text block dimensions change by only a few pixels — a shift that may not even register above the CLS threshold. Use the Font Style Matcher tool (by Monica Dinculescu) to find the right values for your specific font pairing.

Self-Hosting Fonts

Self-hosting eliminates the Google Fonts connection latency entirely and gives you full control over caching headers. Host the font files in your Angular assets/fonts/ directory:

@font-face {
  font-family: 'Inter';
  src: url('/assets/fonts/inter-400.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: optional;
}

Preload the font in index.html to ensure it loads before first render:

<link rel="preload" href="/assets/fonts/inter-400.woff2" as="font" type="font/woff2" crossorigin>

Fix 3: Dynamic Content Injected Above Existing Content

Angular apps frequently inject content after the initial render — cookie consent banners, notification toasts, promotional banners, subscription nudges. When these insert into the DOM above existing content, everything below them shifts down.

Cookie and Consent Banners

/* ❌ Relative positioning — inserts into document flow, shifts content down */
.cookie-banner {
  position: relative;
  width: 100%;
}

/* ✅ Fixed positioning at bottom — no document flow impact */
.cookie-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 1000;
  padding: 1rem;
  background: #1a1a2e;
}

A banner fixed to the bottom of the viewport does not affect document flow. Existing content does not move. CLS contribution: zero.

Reserving Space for Header/Nav Before Angular Bootstraps

Angular's client-side bootstrap takes time. During that window, if your header is Angular-rendered, content may shift as the header appears. Reserve the space with a CSS custom property set in your styles.scss:

// styles.scss
:root {
  --header-height: 64px; /* match your actual header height */
}

main {
  padding-top: var(--header-height);
}

// The Angular-rendered header fills that reserved space
app-header {
  display: block;
  height: var(--header-height);
  position: fixed;
  top: 0;
  width: 100%;
}

Notification and Alert Banners

Never prepend notification banners before existing content. Append them to the bottom of their container, or use a toast pattern with position: fixed. If a banner must appear at the top, reserve its height in the layout before Angular decides to show it:

<!-- Reserve space always, show banner conditionally -->
<div class="banner-slot" [style.height]="showBanner ? 'auto' : '0'" [style.overflow]="showBanner ? 'visible' : 'hidden'">
  @if (showBanner) {
    <app-promo-banner />
  }
</div>

Or, better: use visibility: hidden instead of removing from the DOM so the space is always reserved:

<div class="banner-slot" [style.visibility]="showBanner ? 'visible' : 'hidden'">
  <app-promo-banner />
</div>

Fix 4: Scrollbar Appearing/Disappearing on Route Change

This is one of the most common and overlooked Angular CLS causes. When the user navigates between routes, one page's content may be short (no scrollbar needed) and the next may be long (scrollbar appears). The scrollbar appearing or disappearing shifts the layout horizontally by 15–17 pixels on most operating systems. That shift affects every element on the page and produces a measurable CLS score.

/* styles.scss — the single most effective Angular CLS fix after images */
html {
  scrollbar-gutter: stable;
  overflow-y: scroll; /* fallback for browsers that don't support scrollbar-gutter */
}

scrollbar-gutter: stable reserves the scrollbar space even when no scrollbar is present. The page width remains constant across all routes, whether or not content is scrollable. This single CSS rule eliminates an entire category of Angular routing CLS.

Note on overflow-x: hidden: Adding overflow-x: hidden to body is a common hack to prevent horizontal scroll on mobile. On some browser/OS combinations, it interferes with scrollbar-gutter and can itself trigger layout shifts. If you use it, test carefully and consider scoping it to specific containers rather than the entire body.


Fix 5: Skeleton Loaders Instead of Content Jumps

A common Angular pattern that destroys CLS: show a spinner or nothing while data loads, then replace it with content. When the content appears, it has different dimensions than the empty state, and everything shifts.

The correct pattern is a skeleton loader that exactly matches the dimensions of the content it will be replaced by.

<!-- Angular control flow — conditional skeleton -->
@if (isLoading()) {
  <div class="card-skeleton">
    <div class="skeleton skeleton--image"></div>
    <div class="skeleton skeleton--title"></div>
    <div class="skeleton skeleton--text"></div>
    <div class="skeleton skeleton--text skeleton--text-short"></div>
  </div>
} @else {
  <app-card [data]="cardData()" />
}
/* Skeleton base styles */
.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

/* Match exact dimensions of real content */
.skeleton--image {
  width: 100%;
  aspect-ratio: 16 / 9;
  margin-bottom: 1rem;
}

.skeleton--title {
  height: 1.5rem;
  width: 70%;
  margin-bottom: 0.5rem;
}

.skeleton--text {
  height: 1rem;
  width: 90%;
  margin-bottom: 0.375rem;
}

.skeleton--text-short {
  width: 60%;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

The critical detail: the skeleton must occupy the exact same space as the real content. Measure your real card component's rendered height in Chrome DevTools, then match your skeleton to that height. A skeleton that is 10px shorter than the real content will still cause a shift when the swap happens.

Skeleton loaders pair well with micro-interaction patterns that make loading states feel intentional and polished. See Micro-Interactions: UX Design Patterns and Examples for 2025 for related patterns.


Fix 6: Angular Animations That Shift Layout

Angular's animation module makes it easy to animate layout properties — height, width, margin, padding. Animating these properties forces the browser to recalculate layout on every animation frame and counts as layout shift when the animation starts or ends unexpectedly.

// ❌ Animating height — triggers layout recalculation, can cause CLS
animations: [
  trigger('expand', [
    transition(':enter', [
      style({ height: 0 }),
      animate('300ms ease', style({ height: '*' }))
    ])
  ])
]
/* ❌ Animating layout properties — CLS-causing, also slow */
.panel {
  transition: height 300ms ease, margin 300ms ease;
}

/* ✅ Animate transform and opacity only — GPU-composited, no layout recalculation */
.panel {
  transition: transform 300ms ease, opacity 300ms ease;
}

.panel--hidden {
  transform: translateY(-10px);
  opacity: 0;
  pointer-events: none;
}

For Angular animations specifically, use style({ transform: '...', opacity: '...' }) rather than style({ height: 0 }). For accordion/collapse animations where height must animate, the grid-template-rows trick avoids layout shift:

/* Smooth height animation without shifting layout */
.accordion__content {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms ease;
}

.accordion__content--open {
  grid-template-rows: 1fr;
}

.accordion__inner {
  overflow: hidden;
}

This animates the grid row height rather than the element height directly, which the browser handles without triggering layout shift on surrounding elements.


Fix 7: Ad Slots and Third-Party Embeds

If your Angular app includes ads, embedded videos, or third-party widgets, these are prime CLS sources because their dimensions are often unknown until they load.

Ad Slots

Always declare the ad slot dimensions before the ad loads:

<!-- Reserve space for a standard banner ad -->
<div class="ad-slot ad-slot--banner">
  <!-- Ad script content goes here -->
</div>
.ad-slot--banner {
  min-height: 250px;   /* standard medium rectangle */
  width: 300px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f9f9f9; /* placeholder background while ad loads */
}

.ad-slot--leaderboard {
  min-height: 90px;
  width: 728px;
}

YouTube and Video Embeds

<!-- ❌ iframe without aspect-ratio — shifts layout on load -->
<iframe src="https://www.youtube.com/embed/xyz" frameborder="0" allowfullscreen></iframe>

<!-- ✅ Wrapped with aspect-ratio — space reserved -->
<div class="video-embed">
  <iframe
    src="https://www.youtube.com/embed/xyz"
    title="Demo video"
    frameborder="0"
    allowfullscreen>
  </iframe>
</div>
.video-embed {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
}

.video-embed iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

Google Maps Embeds

.map-embed {
  width: 100%;
  height: 400px; /* fixed height — no CLS */
}

For any third-party embed where you cannot control the loaded content dimensions, the approach is always the same: wrap in a container with explicit dimensions and use position: absolute; inset: 0 on the embed itself to fill that reserved space.


Angular SSR + Hydration: Preventing Hydration CLS

Angular Server-Side Rendering is one of the most powerful CLS improvements available to Angular apps. With SSR, the browser receives fully rendered HTML and paints it immediately — there is no blank-to-content shift caused by JavaScript executing and injecting DOM. For CLS, SSR can drop your score from 0.3+ to near zero on the initial paint.

However, SSR introduces a new CLS risk specific to hydration: if the Angular client-side hydration process finds a mismatch between the server-rendered DOM and what the Angular component would render on the client, Angular tears down the server-rendered nodes and rebuilds them from scratch. That rebuild is visible as a layout shift.

Enabling Full Hydration with DOM Reuse

// app.config.ts
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(withEventReplay()),
    // ...other providers
  ]
};

provideClientHydration() enables non-destructive hydration — Angular reuses the server-rendered DOM rather than replacing it, which eliminates the rebuild shift.

withEventReplay() captures user interactions that happen before hydration completes (button clicks, form inputs) and replays them once the component is ready.

Common Hydration Mismatch Causes

Hydration mismatches (and their resulting CLS) typically come from:

Browser-only APIs: Code that references window, document, or localStorage runs on the client but not the server, producing different output.

// ❌ Mismatch — window not available on server
ngOnInit() {
  this.screenWidth = window.innerWidth;
}

// ✅ Safe check
ngOnInit() {
  if (typeof window !== 'undefined') {
    this.screenWidth = window.innerWidth;
  }
}

Date-dependent content: new Date() on the server produces a different timestamp than on the client, causing mismatch.

Third-party DOM mutation: Libraries that directly manipulate the DOM (jQuery plugins, vanilla JS widgets) will cause hydration mismatches. Wrap them in afterNextRender() so they only run after hydration completes.

import { afterNextRender } from '@angular/core';

constructor() {
  afterNextRender(() => {
    // Safe to use browser APIs and third-party DOM libraries here
    initThirdPartyWidget();
  });
}

For a deep dive into SSR setup and hydration configuration, see Angular SSR and Hydration Performance Guide 2025.


CLS Audit Checklist for Angular Apps

Work through this checklist on every key route of your Angular app. Open Chrome DevTools, run the PerformanceObserver snippet from the measurement section, navigate the app normally, and check each item:

  1. All <img> tags have explicit width and height attributes — or use NgOptimizedImage which enforces this at build time.
  2. NgOptimizedImage is imported and used for all production images in Angular components.
  3. priority attribute is set on the LCP image — typically the hero or above-fold image on each route.
  4. Google Fonts use display=optional or display=swap with size-adjust — never the default (no display parameter).
  5. Font preconnect links are in index.html — fonts.googleapis.com and fonts.gstatic.com with crossorigin.
  6. scrollbar-gutter: stable is set on html in global styles — eliminates scrollbar flicker on route change.
  7. Cookie and consent banners use position: fixed at the bottom of the viewport — never inserted above content.
  8. No notification or promotional banners are prepended above existing content — use fixed, toast, or bottom-anchored patterns.
  9. Skeleton loaders match the exact dimensions of their replaced content — measure in DevTools before writing skeleton CSS.
  10. Angular animations only animate transform and opacity — no height, width, margin, padding, top, or left in transitions.
  11. Ad slots have min-height matching the largest expected ad format before the ad loads.
  12. All iframes and video embeds are wrapped in aspect-ratio containers — YouTube, Google Maps, Loom, Figma embeds.
  13. Angular SSR is enabled and provideClientHydration() is configured — verify no hydration mismatch warnings in the console.
  14. overflow-x: hidden on body has been tested for scrollbar-gutter interference — scope to specific containers if it causes issues.
  15. CrUX field data in PageSpeed Insights shows CLS < 0.1 for all key routes — lab data alone is insufficient; real user data is the ranking signal.

Run Lighthouse after each significant fix to track progress. A reasonable target for an Angular SPA: CLS < 0.05 in Lighthouse lab data and < 0.1 in CrUX field data.


CLS Is a Trust Signal, Not Just a Score

Users do not know what Cumulative Layout Shift is. They will never open Chrome DevTools and look at their CLS score. But they feel every point of it — the misclick on a button that moved, the article text they were reading that jumped down, the form they started filling in that shifted when an ad loaded above it.

A CLS score below 0.1 is a background signal that your site respects the user's attention and time. It is also increasingly a competitive differentiator: most Angular apps in the wild still score 0.2+ on CLS because images and scrollbar-gutter are never addressed. Fixing those two issues alone — NgOptimizedImage across all images, and scrollbar-gutter: stable on the html element — eliminates 60% of Angular CLS problems in most codebases.

Start there. Measure with the PerformanceObserver snippet during development. Then work through the checklist above for the remaining 40%. A clean CLS score is achievable in an afternoon for most Angular apps, and the payoff — better rankings, fewer frustrated users, and a site that feels polished — compounds over time.