---
title: "Cumulative Layout Shift (CLS) - Serpwise"
description: "Learn how CLS measures unexpected layout movement, how to diagnose unstable elements, and how reserved space, image dimensions, and font metrics reduce shifts."
url: "https://serpwise.ai/learn/cumulative-layout-shift/"
source: "https://serpwise.ai/learn/cumulative-layout-shift/"
---
[← Back to Learn](/learn/)

Performance & Core Web Vitals Warning

# Cumulative Layout Shift (CLS)

Learn how CLS measures unexpected layout movement, how to diagnose unstable elements, and how reserved space, image dimensions, and font metrics reduce shifts.

CLS is the sum of layout shift scores for every unexpected movement of visible content during the page’s lifecycle. Google rates it “good” under 0.1, “needs improvement” 0.1-0.25, and “poor” above 0.25, at the 75th percentile of CrUX field data. Unlike LCP and INP, CLS is structural - it’s caused by missing dimensions, late-loading fonts, and dynamic content insertion, not by slow code.

## Why it matters

CLS makes pages feel unstable. The user is reading, content jumps, and they tap the wrong link. Core Web Vitals are one part of Google’s page-experience considerations, but CLS should be fixed first because it directly harms usability, not because it guarantees a ranking change.

The four causes that account for most CLS:

1. **Images without dimensions.** Browser doesn’t know the image height until it loads, so the layout collapses to zero, then snaps to the image’s natural size when the bytes arrive.
2. **Web fonts swapping.** FOUT (Flash of Unstyled Text) or FOIT (Flash of Invisible Text) shifts text when the brand font swaps in, because the fallback and the brand font have different metrics.
3. **Dynamically injected content.** Banners, cookie consent, A/B test variants, late-loading hero sections push content down after first paint.
4. **Ad slots without reserved space.** Empty `<div>` for an ad, then the ad loads at 250px tall, everything below it jumps.

## How to detect it

CrUX field data via PageSpeed Insights is the canonical number. For real-time debugging, the Web Vitals JS library reports each shift with its source:

```
<script type="module">
	import { onCLS } from "https://unpkg.com/web-vitals?module";
	onCLS(
		({ value, entries }) => {
			entries.forEach((entry) => {
				console.log(
					"Shift:",
					entry.value,
					"Sources:",
					entry.sources?.map((s) => s.node),
				);
			});
		},
		{ reportAllChanges: true },
	);
</script>
```

Chrome DevTools → Performance → record a load → the Experience track highlights every layout shift with the source element. This is the fastest way to identify exactly which elements are jumping.

## The fix

### Fix 1 - Always set image and iframe dimensions

```
<!-- Right: aspect ratio reserved, no shift on load -->
<img src="/photo.webp" alt="…" width="800" height="600" />
<iframe src="https://www.youtube.com/embed/…" width="560" height="315"></iframe>
```

`width` and `height` attributes give the browser an aspect ratio it can reserve. CSS can override visual size:

```
img {
	width: 100%;
	height: auto;
}
```

For responsive images with multiple aspect ratios per breakpoint, use the `aspect-ratio` CSS property:

```
.hero-image {
	aspect-ratio: 16 / 9;
	width: 100%;
}
```

### Fix 2 - Stabilize font swap with metric overrides

Match the fallback font’s metrics to the web font so the swap is invisible:

```
@font-face {
	font-family: "Brand";
	src: url("/brand.woff2") format("woff2");
	font-display: swap;
	size-adjust: 105%;
	ascent-override: 90%;
	descent-override: 22%;
	line-gap-override: 0%;
}
```

The `font-display: optional` value avoids CLS entirely by suppressing the swap on slow connections - the page just uses the fallback if the brand font hasn’t loaded by first paint. Trade-off: some users never see the brand font on the first visit.

### Fix 3 - Reserve space for dynamic content

Cookie banners, A/B test variants, hero sections that load late - give them a placeholder with the right height before they arrive:

```
<div class="ad-slot" style="min-height: 250px;">
	<!-- Ad will populate here at 250px tall -->
</div>
```

```
<div class="hero-placeholder" style="aspect-ratio: 16/9; background: #f0f0f0;">
	<!-- Hero swaps in here -->
</div>
```

### Fix 4 - Don’t insert content above existing content

If you must add content dynamically after first paint, add it below the fold or use `position: fixed` / `position: absolute`. Any DOM insertion that pushes existing visible content down is a CLS event.

### Next.js (App Router)

```
import Image from "next/image";

<Image src="/photo.webp" alt="…" width={800} height={600} />;
```

`next/font` automatically generates metric-override CSS for Google Fonts and local fonts:

```
import { Inter } from "next/font/google";
const inter = Inter({
	subsets: ["latin"],
	display: "swap",
	adjustFontFallback: true,
});
```

### WordPress

WP automatically adds `width` and `height` to images uploaded through the media library. Images embedded as raw HTML in post content often lack these - audit with a SQL query for `<img` without `width=`. The Native Lazyload plugin handles dimensions correctly; some lazy-load libraries strip them, causing CLS.

## Common pitfalls

- **Setting dimensions only in CSS.** `width: 100%` in a stylesheet doesn’t help the browser reserve space before the image’s bytes arrive. Use HTML attributes for the intrinsic size.
- **`font-display: block`.** Causes FOIT - invisible text until the font loads. Looks like a clean page, but a 3-second blank text block is its own UX problem.
- **Cookie banners injected at the top of the body.** Push everything down on every page load. Use `position: fixed` overlay instead.
- **Carousels that auto-advance and resize.** If slides have different heights, every transition is a layout shift. Lock the carousel height.
- **A/B test variants loaded via JS after first paint.** The control renders, the variant swaps in 500ms later - every shift counts.

From diagnosis to deployment

## Find the issue. Ship the fix.

Use Learn to understand the problem, then run Serpwise against your own site to see what can be approved and deployed.

[Run free audit](/audit/) [Book a demo](/demo/)