How to fix CLS: what makes the page jump

What counts in CLS, the common causes (images, ads, fonts, animations) and how to reserve space in HTML and CSS. How to find the element that moves.

Check my site freeSee the plans
On this page 7 min read
  1. How CLS is calculated
  2. Common causes and how to fix them
  3. How to find the element that moved
  4. Back/forward cache (bfcache)
  5. How to check your site

CLS (Cumulative Layout Shift) measures how much visible content moves around without the user asking for it. It is good up to 0.1 and poor above 0.25, at the 75th percentile of visits (web.dev). The most common causes are images, ads and iframes without a defined size, content inserted after loading, font swaps and animations that change the layout. Almost all of them are solved by reserving the element's space before it arrives.

This article follows the web.dev guides on CLS and optimizing CLS (updated in February 2025). For the context of the three Core Web Vitals, see the Core Web Vitals guide.

How CLS is calculated

A layout shift happens when a visible element changes position between two frames. Each shift gets a score:

score = impact fraction × distance fraction

  • The impact fraction is the visible area taken up by the elements that moved (old and new positions combined) divided by the area of the screen.

  • The distance fraction is the largest movement of any of those elements, horizontally or vertically, divided by the largest dimension of the screen.

Shifts are grouped into session windows. A window brings together shifts less than 1 second apart and lasts 5 seconds at most. The page's CLS is the total of the window with the highest score, not the sum of every shift in the visit (web.dev). That is why a page left open for a long time does not pile up CLS without limit.

Expected shifts do not count

If the user clicks a button that opens a panel, content moving is the expected result. The API flags shifts that happen within 500 ms of a discrete interaction (tap, click or key press) with hadRecentInput, and they are left out of CLS. Scrolling, dragging and pinch gestures do not open this window (web.dev). If the response to a click takes longer than 500 ms to change the layout, as with a "load more" button that waits on the network, the shift starts to count. In those cases, reserve the space or show a loading indicator as soon as the user clicks.

Common causes and how to fix them

Images without width and height

Without dimensions, the browser does not know how much space to reserve and only finds out when the image arrives, pushing the text down. The fix is to declare width and height in the HTML. Current browsers use these two values to calculate the aspect ratio and reserve the space, even when CSS makes the image responsive (web.dev):

<img src="/img/product.jpg" width="640" height="360" alt="Product description">
img {
  max-width: 100%;
  height: auto;
}

The browser internally applies aspect-ratio: auto 640 / 360. The auto keyword makes the file's real aspect ratio take over once it downloads. The width and height values do not need to match the displayed size; they only need the right ratio.

With responsive images, every option in srcset should have the same aspect ratio. If each screen size uses a different crop, declare the dimensions on each <source> of the <picture>:

<picture>
  <source media="(max-width: 799px)" srcset="/img/banner-480-crop.jpg"
          width="480" height="400">
  <source media="(min-width: 800px)" srcset="/img/banner-800.jpg"
          width="800" height="400">
  <img src="/img/banner-800.jpg" width="800" height="400" alt="Banner description">
</picture>

For elements that have no dimension attributes, such as a video container, the CSS aspect-ratio property reserves the space. It works in Chrome 88, Firefox 89 and Safari 15 onward (MDN browser-compat-data).

.video-container {
  aspect-ratio: 16 / 9;
  width: 100%;
}

Ads, embeds and iframes

Ad slots, embedded videos and third-party widgets tend to arrive after the content and with a variable size. web.dev recommends (web.dev):

  • reserving the space with min-height, or with aspect-ratio when the content is responsive;

  • basing it on the size of the smallest expected creative, with media queries when the size changes between mobile and desktop;

  • keeping a placeholder when no ad is served, instead of collapsing the space, because removing reserved space also causes a shift.

/* Example values: use the size of the smallest ad in each slot */
.ad-top {
  min-height: 100px;
}

@media (min-width: 768px) {
  .ad-top {
    min-height: 90px;
  }
}

Content inserted after loading

Notices, promo banners, forms and lists that appear through JavaScript push down whatever is below them if they have no reserved space. The options from web.dev:

  • reserve the element's space in the initial HTML;

  • place late content further down the page, where the shift affects less of the visible area;

  • in lists and feeds, swap content inside a fixed-size container, or let the user load more with a button (the shift right after the click does not count);

  • load new content off screen and tell the user there is something new, instead of inserting it at the top.

Web fonts

While the web font downloads, the browser shows the text invisible or in a fallback font. When the web font arrives and has different metrics from the fallback, lines change width and height and the content moves. This behavior is controlled by font-display (web.dev):

ValueText invisible for up toSwap to the web fontEffect on CLS
block2 to 3 sat any timecan shift on swap
swap0 msat any timecan shift on swap
fallback100 msup to 3 scan shift on swap
optional100 msdoes not swap laterno shift from font swaps

With optional, the web font is only used if it is ready at first layout. If the brand requires the web font every time, use swap and bring the fallback font's metrics close to it. You do this with an @font-face rule for the local fallback font, adjusted with size-adjust and the metric properties ascent-override, descent-override and line-gap-override (Chrome for Developers):

@font-face {
  font-family: "poppins-fallback";
  src: local("Arial");
  size-adjust: 60.85099821%;
  ascent-override: 164.3358416%;
  descent-override: 57.51754455%;
  line-gap-override: 16.43358416%;
}

body {
  font-family: "Poppins", "poppins-fallback", sans-serif;
}

The values above are the ones from the Chrome article for Poppins over Arial; every font pair has its own, calculated from the files' metrics. A different fallback, such as Roboto on Android, needs another set. On support, according to MDN data: size-adjust works in Chrome 92, Firefox 92 and Safari 17 onward; the three override properties are available in Chrome and Firefox, but in Safari they only appear in the test release (Technology Preview) (MDN browser-compat-data, checked in October 2026). In stable Safari, then, only size-adjust applies.

web.dev also recommends preloading critical fonts with <link rel="preload"> so they arrive earlier. Preloading competes for bandwidth with other resources; use it only for the fonts on the first screen. The same font-display choice affects LCP when the largest element is text (see how to improve LCP).

Animations that change the layout

Animating position and size properties, such as top, left, width and height, recalculates layout and can produce shifts. Use transform (translate, scale, rotate), which moves the element without changing the page flow (web.dev):

/* Avoid */
.notice { position: relative; transition: top 0.3s; }
.notice.open { top: 0; }

/* Prefer */
.notice { transition: transform 0.3s; transform: translateY(-100%); }
.notice.open { transform: translateY(0); }

@media (prefers-reduced-motion: reduce) {
  .notice { transition: none; }
}

How to find the element that moved

  1. In Chrome DevTools, the live metrics view of the Performance panel shows the local CLS and a list of shifts with the element, the time and the score. In a recording, the Layout Shifts track groups the shifts and the "Layout shift culprits" insight points to the likely causes (web.dev).

  2. Lighthouse lists images without width and height and the elements that shifted during loading.

  3. For real-user data, the attribution build of the web-vitals library reports the element behind the largest shift, when it happened, how much it scored and what loading phase the page was in (GitHub GoogleChrome/web-vitals).

import {onCLS} from 'web-vitals/attribution';

onCLS(({value, attribution}) => {
  console.log('CLS', value);
  console.log('Element', attribution.largestShiftTarget);
  console.log('When (ms)', attribution.largestShiftTime);
  console.log('Score', attribution.largestShiftValue);
  console.log('Page state', attribution.loadState);
}, {reportAllChanges: true});

The reportAllChanges option helps with debugging because it reports every increase in CLS; the library README does not recommend using it in production. The API that measures shifts (Layout Instability) only exists in Chromium browsers: web-vitals lists onCLS() support for Chromium only (GitHub GoogleChrome/web-vitals). CLS field data, in RUM or in CrUX, comes from those browsers. The difference between lab and field data is covered in Lighthouse, CrUX and RUM.

Back/forward cache (bfcache)

When the user goes back to a page that was in the bfcache, it is restored from memory exactly as it was, without repeating the loading shifts, and the CLS for that visit starts again from zero (web.dev). That makes it worth keeping pages eligible for this cache. The most common blockers are unload listeners (replace them with pagehide), the Cache-Control: no-store header on the HTML and open connections (IndexedDB, WebSocket, in-flight requests) at the moment the user leaves. In Chrome DevTools, under Application, the Back/forward cache section has a test that shows whether the page was restored and, if not, why.

How to check your site

In WebVyze, the CLS finder uses Google PageSpeed to show where the page stands, and the Core Web Vitals checker gives the CrUX CLS for the last 28 days, on mobile and desktop, for up to 10 URLs. Both tools are free. On the paid plans, a real-visitor script based on the web-vitals library records the CLS of each visit along with its cause. For problems with click responsiveness, see how to improve INP.

Frequently asked questions

Does CLS have a unit, like seconds?
No. It is a unitless number that combines how much of the visible area moved and how far it moved.
Does scrolling count as an interaction that excludes the shift?
No. The 500 ms exclusion applies to taps, clicks and key presses. Scrolling, dragging and pinch gestures do not count.
Do shifts inside iframes count toward CLS?
They count toward the CrUX CLS, because the user sees those movements. Browser APIs cannot measure them, so the web-vitals library does not see them.
Why is the Lighthouse CLS lower than the CrUX CLS?
Lighthouse usually measures only the initial load. CrUX measures the whole life of the page, including what happens after scrolling and interactions. A large difference suggests shifts after loading.
About the author

Daniel Paz

WordPress, performance and SEO specialist, founder of WebOption

Daniel Paz is a WordPress, performance and SEO specialist, the founder of WebOption and an international speaker. With more than 18 years of experience in web development, he helps companies turn their websites into fast, efficient, results-driven platforms.

See what your site needs to fix.

A free check by law, with the cause and the fix guide for each problem.

Check my site freeCreate a free account

Keep reading

Partners

Partner benefits with one coupon.

WPLIGHT

Partner links. We may earn a commission at no extra cost to you.