Cumulative Layout Shift is the sum of the largest layout shift impact scores
inside the worst session window during a page visit. Per web.dev, a passing
score is ≤ 0.1. A score above 0.25 fails. In June 2021, Google replaced
the original cumulative-forever calculation with a session-window model:
shifts get grouped if they happen within 1 second of each other, and a
single window caps at 5 seconds. The page's CLS becomes the largest
window's score, not the page total. That windowing rewards fixing the worst
burst of shift, not every micro-jitter on the page.
Five patterns cause almost every CLS regression. Each has a definitive fix.
Images without explicit dimensions
An <img> tag with no width or height reserves zero pixels until the
binary downloads. When the bytes arrive, the browser reflows the document
and pushes everything below the image down. That reflow is a layout shift,
and it scales with the size of the image relative to the viewport.
Fix: set both width and height HTML attributes on every <img> and
<video>. Modern browsers compute aspect-ratio from those attributes and
reserve the box before the file loads. Per web.dev's image guidance, this
works for responsive images too — the intrinsic ratio is what matters, not
the rendered pixel size.
<img
src="/hero.webp"
width="1280"
height="720"
alt="Dashboard screenshot"
style="width: 100%; height: auto;"
/>For background images or CSS-driven media, declare aspect-ratio on the
container:
.media {
aspect-ratio: 16 / 9;
width: 100%;
background: var(--placeholder);
}Gotcha: srcset does not change the aspect ratio. Every candidate inside a
srcset must share the same intrinsic ratio as the width/height
attributes, or you reintroduce the shift on viewport resize.
Web fonts without font-display
A custom font that swaps in late re-flows every line it touches. Different
fonts have different x-height, cap-height, and advance widths, so the
swap rarely lands on the same line breaks the fallback used.
Fix has two parts. First, declare font-display: optional for fonts you
treat as decoration, or font-display: swap paired with a preload link for
fonts that carry brand identity. Second, tune the fallback with
size-adjust, ascent-override, and descent-override so the fallback
metrics match the web font before it loads.
<link
rel="preload"
href="/fonts/inter-variable.woff2"
as="font"
type="font/woff2"
crossorigin
/>@font-face {
font-family: "Inter";
src: url("/fonts/inter-variable.woff2") format("woff2-variations");
font-display: swap;
font-weight: 100 900;
}
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}Gotcha: font-display: optional gives the browser a 100ms window to
fetch the font. Miss the window, and the font never swaps for that
pageview. Use it for body copy where the swap hurts more than the missing
font does.
Ad slots that fill in late
Ad networks inject creative into a slot after the auction resolves. If the slot has no reserved height, the ad pushes content down on arrival. Header bidding makes this worse — the slot waits on a network round-trip before sizing.
Fix: reserve the largest slot dimension as a min-height on the
container. The slot will hold its space even when the ad is shorter, and
multi-size slots stop shifting between auctions.
<div
class="ad-slot"
data-slot="leaderboard"
style="min-height: 250px; min-width: 300px;"
></div>Gotcha: sticky ads that resize on scroll create new shifts inside the session window. Lock the slot to a single size for the first viewport, and only allow size changes after a user interaction.
Cookie banners and CMS-injected content
A consent banner that mounts at the top of the document body pushes the hero down on every first visit. Same problem for CMS modules that inject promo bars, A/B test variants, or geo-targeted notices after hydration.
Fix: render the banner with position: fixed so it overlays the document
instead of displacing it. Better, render the consent state server-side from
the cookie and ship the banner only when consent is missing.
.consent-banner {
position: fixed;
inset: auto 0 0 0;
z-index: 50;
padding: 1rem;
background: var(--surface);
box-shadow: 0 -1px 0 var(--border);
}Gotcha: position: fixed solves the shift but covers content. Pair it
with padding-bottom on the page or a dismissible close action so users
can reach the footer.
Async widgets and skeleton-less components
Embeds, comment threads, related-posts widgets, and any client-fetched
component that mounts after first paint will shift the surrounding layout
when it arrives. React, Vue, and Svelte all do this when a Suspense
boundary or await resolves into real content above the fold.
Fix: render a fixed-size skeleton in the same DOM position as the final
component. The skeleton must match the eventual height and width,
including any margins. For lists, render n skeleton rows where n is
the average count from your analytics.
<section class="comments" style="min-height: 480px;">
<div class="skeleton skeleton-row"></div>
<div class="skeleton skeleton-row"></div>
<div class="skeleton skeleton-row"></div>
</section>Gotcha: content-visibility: auto skips rendering off-screen sections to
speed up initial paint, but it can introduce shift when the user scrolls
into the skipped region. Pair it with contain-intrinsic-size to declare
a placeholder size for the skipped block:
.below-fold {
content-visibility: auto;
contain-intrinsic-size: 0 800px;
}Verify before shipping
Run isitready.dev on your canonical origin to get
a CLS reading from a real Lighthouse pass, with the offending elements
listed by impact score. The scanner uses the same web-vitals.js library
Google ships, so the numbers match what Chrome User Experience Report will
collect from real users. Catch the shift in CI, not in the field.