← Articles
7 min read

Cumulative Layout Shift (CLS): Making Sense of Visual Stability

CLS is one of Google's Core Web Vitals. It measures how much your page jumps around while loading. Here's what causes it and how to fix it.


What is CLS?

Unexpected layout shifts can disrupt the user experience in many ways, from causing them to lose their place while reading if the text moves suddenly, to making them click the wrong link or button. In some cases, this can do serious damage.

A button shifting down unexpectedly as content loads above it, causing a misclick
A late-loading element pushes the button down right before the user taps it.

Google considers a CLS score of 0.1 or below as good, 0.1–0.25 needs improvement, and anything above 0.25 is poor. It is one of the three Core Web Vitals that Google uses as a ranking signal.

How to Measure CLS

Handwritten notes explaining how to calculate a layout shift score
Layout shift score = impact fraction × distance fraction
Handwritten notes showing the CLS calculation with a worked example
CLS is the sum of all individual layout shift scores during a session.

Each individual layout shift score is the product of two fractions:

  • Impact fraction — how much of the viewport the unstable element affected
  • Distance fraction — how far the element moved relative to the viewport

CLS is the sum of all layout shift scores that happen within a 5-second session window. Here are the tools you can use to measure it:

  • Lighthouse: Run it in Chrome DevTools (Lighthouse tab) or via the CLI. It runs a simulated load and gives you a lab CLS score. Good for catching regressions before shipping.
  • WebPageTest: Runs a real browser on real hardware from a real network location. Use the filmstrip view to see exactly which frame triggered the shift.
  • Real User Monitoring (CrUX): The Chrome User Experience Report captures CLS from actual users visiting your site. You can view your field data in PageSpeed Insights or the Core Web Vitals report in Google Search Console. This is the number that matters for SEO.

Common Causes of CLS

1. Unsized Images

If you don’t give an image a size, the browser assumes it’s 0px tall until the file actually downloads. Once it hits, the browser has to “shove” everything else down to make room.

An image loading without reserved dimensions, pushing content below it downward
No width/height → the browser has no idea how much space to reserve.

How to solve? Just add width and height attributes to your <img> tags. Modern browsers use these to calculate the aspect ratio immediately, reserving the space so the rest of your layout stays put. One line fixes it.

<!-- Bad: browser doesn't know the height until the image downloads -->
<img src="hero.jpg" alt="Hero image" />

<!-- Good: browser reserves the correct space immediately -->
<img src="hero.jpg" alt="Hero image" width="1200" height="630" />

2. Slow-Loading Fonts

Web fonts usually mess up your layout in two ways:

  • FOIT (Flash of Invisible Text): The text is invisible until the font loads. When it appears, the line heights can shift.
  • FOUT (Flash of Unstyled Text): The browser shows a basic system font first, then “snaps” to your web font. If the sizes don’t match, your paragraphs will jump.
FOUT: the fallback font and the web font have different metrics, causing a jump when the web font loads.

How to solve:

  • Preload: Get the font file early using <link rel="preload"> so it is less likely to be late.
  • CSS Font Descriptors: This is the best way to handle it. Use @font-face properties like size-adjust and ascent-override to make your fallback font match your web font’s dimensions. The “snap” never happens because the two fonts look identical in size.
@font-face {
  font-family: 'Lato';
  src: url('/static/fonts/Lato.woff2') format('woff2');
  font-weight: 400;
}

@font-face {
  font-family: "Lato-fallback";
  size-adjust: 97.38%;
  ascent-override: 99%;
  src: local("Arial");
}

h1 {
  font-family: Lato, Lato-fallback, sans-serif;
}

The Lato-fallback family is a tweaked version of Arial that is tuned to match Lato’s metrics. When Lato loads and swaps in, the layout doesn’t move because the two fonts are already the same size.

3. Animations That Trigger Layout Shifts

If you animate things using top, left, width, or height, you’re forcing the browser to recalculate the layout on every single frame. It’s heavy, it’s slow, and it causes layout shifts for surrounding elements.

How to solve:

  • Use transform: translate() instead of top / left.
  • Use transform: scale() instead of width / height.
/* Bad: triggers layout + paint on every frame */
.toast {
  transition: top 0.3s ease;
}

/* Good: compositor-only, zero layout cost */
.toast {
  transition: transform 0.3s ease;
}

The “why”: Transforms happen on the compositor thread (the GPU), not the main thread. The browser skips layout and paint entirely — it just moves the already-painted layer. It’s smoother and doesn’t shift anything else on the page.

4. Injecting Content Above the Fold

Think of things like top banners, cookie notices, or ads that pop in late. If you’re dropping a div into the top of the page after the UI has already rendered, you’re guaranteed a shift. Every element below the injected content gets shoved down.

The fix is to reserve the space upfront — use min-height on the container so the slot exists in the layout before the content fills it.

5. Flash of Unstyled Content (FOUC)

If your CSS loads late or is injected via JavaScript, the browser renders raw HTML first. Then, when styles arrive, it “snaps” into your design. That visual jump is a major CLS hit.

Avoid loading stylesheets dynamically with JavaScript for anything above the fold. Keep critical CSS in a <link rel="stylesheet"> in <head> so it’s render-blocking — that’s actually what you want for critical styles.

Some Tricks to Avoid CLS

1. Reserve Space for Your App

In SPAs, the #app container is often empty until your JavaScript bundles load and hydrate. During that window, the footer or skeleton content can jump around. Setting a min-height on your main container keeps things anchored.

#app {
  min-height: 900px;
}

You can refine this with min-height: 100dvh if the goal is to fill the viewport, or use a skeleton screen that matches the real layout’s dimensions.

2. Use aspect-ratio

The CSS aspect-ratio property prevents CLS by reserving the exact space an image, video, or ad will occupy before it loads. The browser calculates the height from the width using the ratio — no JavaScript required.

Before: no aspect-ratio — the content below jumps when the video loads.
After: aspect-ratio reserves the space — zero layout shift.
/* The container holds its dimensions before the media loads */
.video-wrapper {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: var(--color-border); /* optional placeholder color */
}

.video-wrapper video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

3. Lighthouse CI

Use Lighthouse CI for regression testing. You can configure it to run on every commit in your CI pipeline. If a change pushes your CLS above 0.1, the build fails before it ever reaches production.

# lighthouserc.yml
ci:
  assert:
    assertions:
      cumulative-layout-shift:
        - error
        - maxNumericValue: 0.1

This turns CLS from something you check manually into a hard gate — the same way you’d fail a build on a failing unit test.

References

  1. web.dev: Cumulative Layout Shift
  2. WICG: Layout Instability Specification
  3. web.dev: Optimize CLS – Animations
  4. YouTube: Optimize Cumulative Layout Shift

performance · core-web-vitals · css