Cumulative Layout Shift Fixes for Static Sites

Cumulative Layout Shift measures how much visible content jumps around after it first paints. It is the one Core Web Vital where a static site should have an unfair advantage: the markup is fully known at build time, so nothing needs to appear late. In practice static sites still score badly, and almost always for the same four reasons — media without dimensions, web fonts that swap after first paint, embeds injected by scripts, and stylesheets that arrive after the first render pass.

This guide works through each cause, the build-time fix for it, and the measured CLS before and after. It sits under Performance Optimization & Core Web Vitals for SSGs alongside Largest Contentful Paint Optimization for Static Sites; LCP asks when the main content appears, CLS asks whether it stays where it landed.

Anatomy of a layout shift on a static page Two stacked renderings of the same article. In the first frame an unsized image collapses to zero height and the heading sits near the top. In the second frame the image has loaded at 320 pixels tall and pushed the heading and paragraph down, producing an impact fraction of 0.62 and a distance fraction of 0.40 for a layout shift score of 0.25. One unsized image, one 0.25 layout shift Frame 1 — image not yet decoded Section heading Frame 2 — image arrives at 320 px tall hero.jpg decoded Section heading (moved) impact fraction 0.62 × distance fraction 0.40 = layout shift score 0.25
The score is the fraction of the viewport that moved multiplied by how far it moved. One unsized image in the article body is enough to blow the 0.1 budget on its own.

How the Score Is Actually Computed

Every time a visible element changes position between two frames, the browser records a layout shift value: the impact fraction (the share of the viewport occupied by the moving content, before and after, combined) multiplied by the distance fraction (the greatest distance any element moved, divided by the viewport's largest dimension). CLS is the largest burst of those values within a 5-second window, where a burst is a run of shifts separated by less than a second.

Two consequences matter when you are hunting shifts on a static site. First, a small element that moves a long way scores about the same as a large element that moves slightly, so a 40-pixel banner that pushes the whole article down is not a small problem. Second, because the metric takes the worst burst, fixing three of four shifts that happen together may not move your score at all — the remaining one still anchors the burst. Fix causes in the order the frames reveal them, not in the order that is easiest.

Shifts within 500 ms of a user interaction are excluded, which is why an accordion or a menu that expands on click is free. Nothing else gets a pass: a font swap, a lazily injected embed, or a banner appearing 900 ms after load all count in full.

Reserve Space for Every Image and Embed

The single biggest source of static-site CLS is media without intrinsic dimensions. An <img> with no width/height attributes has zero height until its bytes arrive, so everything below it sits too high and then jumps. Always emit both attributes and let CSS scale the box:

<img src="/img/hero.avif" width="1200" height="675" alt="Build pipeline overview"
     style="max-width:100%;height:auto" loading="eager" fetchpriority="high">
/* Keeps the reserved box correct when responsive rules change the width */
.prose img { max-width: 100%; height: auto; aspect-ratio: attr(width) / attr(height); }

Most generators can do this for you. Astro's <Image> and <Picture> read the intrinsic size from the imported file and stamp it into the markup — one of the reasons Image Optimization Pipelines in Astro is worth adopting even if you only care about layout stability. Hugo exposes .Width and .Height on any resource, and Eleventy's image plugin returns the dimensions with the generated markup.

Iframes are worse than images because they never have an intrinsic size at all. Wrap them in an aspect-ratio box:

.embed { aspect-ratio: 16 / 9; width: 100%; }
.embed > iframe { width: 100%; height: 100%; border: 0; }
PageCLS beforeCLS afterChange made
Article with 6 body images0.310.01width/height on every <img>
Tutorial with a video embed0.240.00aspect-ratio wrapper on the iframe
Docs index with avatar grid0.080.00Fixed 48 px avatar box

The full recipe, including the responsive-image case where the aspect ratio changes per breakpoint, is in Reserving Space for Images and Embeds to Stop Layout Shift.

Stop Fonts From Reflowing the Text

A web font that loads after first paint re-renders every line it touches. With font-display: swap the fallback shows immediately and then swaps — good for LCP, bad for CLS if the two faces have different metrics, because the text reflows and everything below it moves.

Font swap with and without metric overrides Two timelines. The top timeline shows a fallback face rendering at 0.4 seconds, the web font arriving at 1.1 seconds and the text reflowing three lines, shifting the content below by 46 pixels. The bottom timeline uses size-adjust and ascent-override so the swap changes glyph shapes only and the content below stays at zero offset. The swap is free only when the metrics match Unadjusted fallback 0.4 s · fallback paints 1.1 s · web font swaps 3 lines reflow 46 px shift · CLS 0.14 size-adjust + ascent-override 0.4 s · fallback paints 1.1 s · web font swaps same line boxes 0 px shift · CLS 0.00 Measured on a 900-word article, Chrome 4× CPU throttle, fast 3G profile
Matching the fallback's metrics to the web font makes the swap invisible to layout: glyph shapes change, line boxes do not.

Declare a metric-matched fallback so the fallback occupies exactly the space the real font will:

@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 107.4%;
  ascent-override: 90%;
  descent-override: 22.4%;
  line-gap-override: 0%;
}

body { font-family: Inter, 'Inter Fallback', system-ui, sans-serif; }

Self-hosting the font removes the extra DNS lookup and connection that delay the swap in the first place — see Self-Hosting Google Fonts to Eliminate Layout Shift and the wider Font Loading Strategies for Static Sites. On a documentation site we measured, self-hosting plus size-adjust took CLS from 0.14 to 0.00 and shaved 180 ms off the swap.

Keep Late-Arriving Scripts Out of the Layout

Anything a script inserts into the document flow after paint shifts everything below it: consent banners, newsletter forms, comment widgets, ad slots, "related posts" pulled from an API. On a static site these are the shifts that survive after you have fixed the media.

Three rules cover almost every case:

  1. Render the container at build time. If a widget will occupy 320 px, emit an empty 320 px box in the HTML and let the script fill it. The generator already knows the widget exists.
  2. Take overlays out of flow. A cookie banner should be position: fixed over the page, not a block prepended to <body>.
  3. Never inject above existing content. Appending below the fold is cheap; prepending is the most expensive shift a page can make.
<!-- Space reserved at build time; the script only fills it -->
<div class="comments" style="min-height:420px" data-comments="post-42"></div>

The comparison table below is from a real audit of a docs site that added three widgets over a year:

Injected elementPlacementCLS contribution
Consent banner prepended to bodyin flow, top0.19
Same banner as fixed overlayout of flow0.00
Comments loaded on scroll, no reserved boxin flow, mid-page0.11
Comments with min-height reservedin flow, mid-page0.00

Lazy-loaded embeds need the same treatment, which is covered end to end in Fixing CLS From Late-Loading Embeds and, from the byte-cost angle, in Third-Party Script Performance on Static Sites.

Ship CSS Before the First Render, Not After

A stylesheet that arrives after the browser has painted causes a re-layout of everything it touches. On static sites this usually shows up in one of two ways: a deferred "non-critical" stylesheet that turns out to control layout, or a JavaScript-injected style block from a component library.

Inline the CSS that governs above-the-fold structure and load the rest with a non-blocking pattern that cannot reorder your layout:

<style>/* critical: header, hero, typography scale, grid */</style>
<link rel="preload" href="/css/rest.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/rest.css"></noscript>

Keep the deferred file free of layout-defining rules — colours, hover states and print styles are safe; grid definitions and font sizes are not. The render-blocking side of this trade-off is covered in Eliminating Render-Blocking CSS on Static Sites.

Find Shifts Instead of Guessing

Chrome DevTools shows the culprit directly: open Performance, record a load with 4× CPU throttling, and look at the Layout Shifts track — each entry names the moved node and shows before/after rectangles. For a whole-site sweep, script it with the Layout Instability API:

// node scripts/cls-audit.mjs — prints CLS and the worst node per URL
import puppeteer from 'puppeteer';

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.evaluateOnNewDocument(() => {
  window.__shifts = [];
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (!entry.hadRecentInput) window.__shifts.push(entry);
    }
  }).observe({ type: 'layout-shift', buffered: true });
});
await page.goto(process.argv[2], { waitUntil: 'networkidle0' });
const total = await page.evaluate(() =>
  window.__shifts.reduce((sum, s) => sum + s.value, 0));
console.log(process.argv[2], total.toFixed(3));
await browser.close();

Run it over your sitemap in CI and fail the build when a page regresses past 0.05. Two details make the difference between a useful gate and a flaky one. Set a fixed viewport and device scale factor, because the distance fraction is normalised against viewport size and a different window silently changes every score. And take the median of three runs rather than a single sample: a cold-cache run that loses a font race can double a page's score without anything in the repository having changed. A gate that cries wolf gets disabled within a fortnight, which is worse than not having one.

Sample the pages that differ structurally rather than every URL — one article template, one index, one page with an embed and the homepage will surface a regression that a hundred near-identical article pages would only repeat. That keeps the check inside a minute even on a large site, so it can run on every pull request instead of nightly. Lab numbers are only half the story though: the field data from real devices is what Core Web Vitals reports, and it captures shifts a scripted run never triggers. Measuring CLS in the Field With web-vitals.js covers the collection side, including how to attribute a field score to a specific element.

CLS budget burn-down across four fixes A stacked burn-down chart. The starting CLS of 0.42 falls to 0.23 after images get dimensions, to 0.12 after the consent banner becomes an overlay, to 0.04 after font metric overrides, and to 0.01 after the deferred stylesheet is inlined. A dashed line marks the 0.1 good threshold, crossed at the third fix. Burning down 0.42 CLS on one docs template 0.45 0.00 0.10 good threshold 0.42 0.23 0.12 0.04 0.01 baseline image dims banner overlay font metrics inline CSS Chrome 4× CPU throttle, fast 3G, median of 5 runs · lower is better
Fixes are not equal: images and the banner accounted for 71% of the score on this template, and the last two changes were what took it from "needs improvement" to effectively zero.

What Each Generator Gives You for Free

The four causes above are universal, but how much your generator prevents by default varies enough to change where you spend the effort.

Astro is the strongest out of the box. Images imported through astro:assets carry their intrinsic dimensions into the markup, and island components are server-rendered first, so hydration replaces markup that already occupies the right space. The residual risk is a client:only island — it renders nothing on the server, so whatever it occupies appears after hydration. Give those a wrapper with a fixed min-height, or move them to client:visible with server-rendered fallback content.

Hugo gives you the dimensions but not the markup: .Width and .Height are available on every image resource, and it is on you to emit them. A render hook is the cheapest way to make that automatic for Markdown images:

{{/* layouts/_default/_markup/render-image.html */}}
{{- $img := .Page.Resources.GetMatch .Destination -}}
{{- with $img -}}
  <img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}"
       alt="{{ $.Text }}" loading="lazy" decoding="async">
{{- else -}}
  <img src="{{ .Destination }}" alt="{{ .Text }}" loading="lazy">
{{- end -}}

Once that hook exists, every image in every Markdown file gets a reserved box without an author remembering anything — the same leverage described in Speeding Up Hugo Builds With Render Hooks and Caching, applied to layout stability instead of build time.

Eleventy ships no image handling by default, so the eleventy-img plugin's shortcode is the place to enforce it; it returns fully formed <picture> markup with dimensions on every <source>. Because Eleventy templates are plain functions, a build-time guard is easy: walk the generated HTML in an eleventy.after hook and fail the build on any <img> missing width.

Next.js static export covers images through next/image, but the export target disables the default image optimizer, so you configure images.unoptimized or a custom loader — and an unoptimized <img> fallback still needs explicit dimensions. The gotcha specific to this target is client-only content: anything rendered inside a useEffect is absent from the exported HTML and lands after hydration. See Handling Dynamic Routes in Next.js Static Export for the related export-time constraints.

GeneratorDimensions by defaultMain residual risk
AstroYes, via astro:assetsclient:only islands render late
HugoAvailable, not emittedMarkdown images without a render hook
EleventyNoPlugin-free <img> in templates
Next.js exportVia next/imageEffect-rendered content missing from HTML

A useful rule when you are choosing between them for a content site: the generator that renders more of the page at build time has less surface for shift, which is one of the practical trade-offs weighed in the SSG Framework Selection Matrix.

Common Pitfalls

  • Trusting a green lab score. Lighthouse stops recording after the run; a widget that loads on scroll never fires. Always cross-check with field data.
  • height: auto without intrinsic dimensions. The CSS is correct but the browser still has nothing to reserve until bytes arrive. The attributes are what create the box.
  • Aspect ratios that differ per breakpoint. Art-directed <picture> sources with different ratios re-reserve space on resize; give each <source> its own width/height.
  • Animating layout properties. Transitioning height, top or margin registers as shifts; animate transform and opacity instead, which never trigger layout.
  • Fixing the small shifts first. CLS takes the worst burst, so clearing three tiny shifts inside a burst dominated by a fourth changes nothing.
  • Forgetting the scroll case. Content revealed on scroll counts fully. If it is lazy, it still needs a reserved box.

Key Takeaways

  • Emit width and height on every image and an aspect-ratio wrapper on every iframe — this alone usually halves a bad score.
  • Self-host fonts and metric-match the fallback with size-adjust, so the swap changes glyphs and not line boxes.
  • Reserve space at build time for anything a script will inject, and put overlays out of flow.
  • Keep layout-defining CSS in the first render pass; defer only rules that cannot move anything.
  • Measure both ways: a scripted Layout Instability run in CI for regressions, plus field CLS for what readers actually experience.

FAQ

What CLS score should a static site aim for?

Under 0.1 at the 75th percentile of real page loads, which is the Core Web Vitals "good" threshold. A static site that reserves space for every image, embed and font has no structural reason to score above 0.02, so treat anything above 0.05 as a bug with a specific cause rather than as noise.

Why does my CLS look fine in Lighthouse but bad in field data?

Lighthouse measures a single cold load in a fixed viewport and stops recording at the end of the run. Field data captures the whole page lifespan, including shifts caused by lazy-loaded embeds, consent banners, A/B test swaps and content that arrives when a reader scrolls. Collect field CLS with the web-vitals library before assuming a lab score represents reality.

Does aspect-ratio replace width and height attributes on images?

No, use both. The width and height attributes give the browser an intrinsic aspect ratio before any CSS parses, which is what protects the very first layout. The CSS aspect-ratio property keeps that box correct when responsive rules override the rendered width.

Do layout shifts below the fold count?

Yes. CLS is measured across the whole viewport whenever content is visible, so a shift that happens while the reader has scrolled to that section counts fully. Shifts in areas the reader never sees are not recorded, which is why lazy-loaded embeds must reserve space too.

Is a shift caused by a user click counted?

Shifts within 500 milliseconds of a user interaction are excluded, so an accordion that expands on click is free. That exclusion window is narrow and does not cover a script that reflows the page 800 milliseconds after a click, so do not rely on it to excuse slow interactions.

How does a static generator help with CLS compared with a server-rendered app?

A static generator knows the final markup at build time, so it can stamp intrinsic dimensions, inline critical CSS and pre-render every element that would otherwise appear after hydration. Most CLS on a static site therefore comes from things added after the build, such as third-party embeds and remote fonts.