PageSpeed Insights flags Avoid large layout shifts, Search Console reports CLS issue: more than 0.1, or you can simply see text jump as images and banners load. Cumulative Layout Shift measures exactly that: visible content moving without the user asking it to. This guide shows how to find the element that is shifting and fix cumulative layout shift at its source, cause by cause.
Quick fix
Most CLS comes from content that loads without reserved space. Start here:
- Give every
<img>,<video>and<iframe>explicitwidthandheightattributes, or a CSSaspect-ratio. - Reserve space for anything injected later (ads, embeds, cookie notices, promo bars) with
min-height, or overlay it withposition: fixed. - Stop web fonts from reflowing text: preload the main font and match the fallback font’s metrics.
img,
video {
max-width: 100%;
height: auto; /* keeps the ratio from the width/height attributes */
}
.embed-16x9 {
aspect-ratio: 16 / 9;
width: 100%;
}
.ad-slot-leaderboard {
min-height: 250px; /* the tallest creative this slot serves */
}
What CLS measures
Each unexpected shift gets a score based on how much of the viewport moved and how far. Shifts are grouped into “session windows” (bursts of shifts no more than one second apart, lasting up to five seconds), and your CLS is the worst window. Shifts within 500 ms of a click, tap or key press are excluded, because the user caused them. Google’s threshold is 0.1 or less for “good”, measured at the 75th percentile of real visits.
Causes, most common first
1. Images and iframes without dimensions
An image without width and height takes up zero height until it downloads, then pushes everything below it down. Modern browsers use the two attributes to compute an aspect ratio before the image loads, so the space is reserved even in a fluid layout with height: auto:
<img src="/images/team.jpg" width="1200" height="800" alt="Our team" loading="lazy">
<iframe src="https://www.youtube-nocookie.com/embed/VIDEO_ID"
width="560" height="315" title="Product demo"
loading="lazy"></iframe>
For responsive embeds, use aspect-ratio on the wrapper or the iframe instead of the old padding-top hack. Background images in a hero need an explicit height or aspect-ratio on the container too.
2. Web fonts swapping in
With font-display: swap, text first renders in a fallback font and then re-renders in the web font. If the two fonts have different widths and line heights, paragraphs rewrap and everything below moves. Three fixes, best used together:
- Preload the critical font file so it usually arrives before first render:
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
- Adjust the fallback font’s metrics to match the web font with
size-adjustand the override descriptors, so the swap barely moves anything:
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
/* Example values: compute them for your font pair */
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: "Inter", "Inter Fallback", sans-serif;
}
Don’t guess these numbers. Tools such as Capsize or Fontaine calculate them from the font files, and next/font in Next.js generates them automatically.
- Consider
font-display: optionalfor body text. If the font isn’t ready almost immediately, the browser keeps the fallback for that page view and never swaps, so there is no shift.
3. Late-injected banners, cookie notices and ads
Anything inserted at the top of the page after first render pushes the whole page down: cookie consent bars, “free shipping” strips, newsletter prompts, ad slots that expand. Options, in order of preference:
- Render it in the server HTML so it is there from the first paint.
- Overlay it with
position: fixedorstickyso it covers content instead of displacing it. A fixed cookie bar at the bottom of the screen causes no layout shift. - Reserve its space with
min-heighton a placeholder, and keep the placeholder even if no ad fills it.
4. Animating layout properties
Animating top, left, height, margin or width moves elements through layout, and every frame can count as a layout shift when it isn’t triggered by user input, for example an auto-playing carousel or an accordion that opens on load. Animate transform and opacity instead; they don’t change layout and aren’t counted:
/* Shifts layout */
.toast { transition: top 0.3s; }
/* Doesn't */
.toast {
transform: translateY(-100%);
transition: transform 0.3s ease;
}
.toast.is-visible { transform: translateY(0); }
5. Lazy loading above the fold and placeholder-less content
This is the one experienced developers miss. Native loading="lazy" with dimensions is fine, but older JavaScript lazy-load scripts swap a tiny placeholder or data-src for the real image and change its size. Client-rendered sections such as reviews, related products or “load more” lists appear with no height reserved. And content-visibility: auto without contain-intrinsic-size renders off-screen sections at zero height, so the page jumps as you scroll to them. Don’t lazy-load images in the first viewport, give skeletons the real content’s size, and pair content-visibility with an estimate:
.below-fold-section {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}
Step-by-step diagnosis
- Check field data first. PageSpeed Insights shows Chrome UX Report (CrUX) data for real visitors over the last 28 days, and Search Console’s Core Web Vitals report groups affected URLs. Field CLS includes shifts during scrolling and later in the visit, which a lab test never sees.
- Run Lighthouse in DevTools. The “Avoid large layout shifts” audit lists the elements that moved during page load. Lighthouse only measures the load, so a low lab score with poor field CLS points to shifts after load.
- Visualise shifts. In Chrome DevTools, open the Rendering panel and enable “Layout Shift Regions”, then reload and scroll. The Performance panel also shows layout shifts in its own track with the moved nodes.
- Log shifts in the console with a
PerformanceObserverto see each shift and its source nodes:
let total = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // user-caused, not counted
total += entry.value;
console.log(
'Layout shift:', entry.value.toFixed(4),
'running total:', total.toFixed(4),
entry.sources.map((s) => s.node)
);
}
}).observe({ type: 'layout-shift', buffered: true });
This simple sum ignores session windows, so treat it as a debugging aid rather than your official score.
- Collect attribution from real users with Google’s
web-vitalslibrary, which applies the session window logic and names the element behind the largest shift:
import { onCLS } from 'web-vitals/attribution';
onCLS(({ value, attribution }) => {
navigator.sendBeacon('/analytics/cls', JSON.stringify({
value,
target: attribution.largestShiftTarget,
page: location.pathname,
}));
});
Prevention
- Make
widthandheightmandatory in your image component or template. - Design placeholders for every async slot: ads, embeds, reviews, recommendations.
- Use fixed or overlay positioning for notices instead of inserting them above content.
- Ship metric-matched fallback fonts and preload only the one or two critical font files.
- Monitor field CLS after each release; a lab score alone won’t catch scroll-time shifts. My WordPress Core Web Vitals checklist and Next.js technical SEO checklist include CLS-safe defaults for each platform.
Key takeaways
- CLS is caused by content arriving without reserved space; reserve it up front.
width/heightattributes oraspect-ratiofix image and iframe shifts.- Preloading plus
size-adjustand metric overrides stop font-swap reflow. - Animate
transformandopacity, never layout properties. - Use field data (CrUX, Search Console) to judge, and
PerformanceObserverorweb-vitalsattribution to find the shifting element.
Frequently asked questions
What is a good Cumulative Layout Shift score?
Google treats 0.1 or less as good and anything above 0.25 as poor, measured at the 75th percentile of real page loads. Between those values it is rated “needs improvement”.
Why is my CLS fine in Lighthouse but poor in PageSpeed Insights field data?
Lighthouse only measures the initial load in a lab, while field data includes shifts that happen while real users scroll and interact. Late-loading ads, lazy content and content-visibility sections are the usual suspects.
Do CSS transforms cause layout shift?
No. Animating transform or opacity doesn’t change layout, so it isn’t counted. Animating top, height or margin can be.
Does a cookie banner hurt CLS?
Only if it pushes content down. A banner with position: fixed that overlays the page doesn’t cause layout shift.
If your CLS still won’t drop below 0.1, get in touch and I’ll find the shifting elements and fix them on your site.