Skip to content
← Tilbage til Learn
Performance & Core Web Vitals Advarsel

Cumulative Layout Shift (CLS)

CLS measures unexpected layout movement during page lifecycle. Threshold for 'good' is 0.1. The fix is almost always reserving space — images with dimensions, fonts with metric overrides, ad slots with min-height.

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, they tap the wrong link. The ranking impact is small in isolation (page experience is a tiebreaker), but the engagement signal is real — high CLS correlates with measurable bounce rate increases on mobile, and users blame the brand, not the browser.

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.
Fra diagnose til live rettelse

Find issue'et. Få rettelsen live.

Brug Learn til at forstå problemet, og kør derefter Serpwise på dit eget site for at se, hvad der kan rettes og komme live.