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.
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; }
| Page | CLS before | CLS after | Change made |
|---|---|---|---|
| Article with 6 body images | 0.31 | 0.01 | width/height on every <img> |
| Tutorial with a video embed | 0.24 | 0.00 | aspect-ratio wrapper on the iframe |
| Docs index with avatar grid | 0.08 | 0.00 | Fixed 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.
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:
- 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.
- Take overlays out of flow. A cookie banner should be
position: fixedover the page, not a block prepended to<body>. - 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 element | Placement | CLS contribution |
|---|---|---|
| Consent banner prepended to body | in flow, top | 0.19 |
| Same banner as fixed overlay | out of flow | 0.00 |
| Comments loaded on scroll, no reserved box | in flow, mid-page | 0.11 |
Comments with min-height reserved | in flow, mid-page | 0.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.
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.
| Generator | Dimensions by default | Main residual risk |
|---|---|---|
| Astro | Yes, via astro:assets | client:only islands render late |
| Hugo | Available, not emitted | Markdown images without a render hook |
| Eleventy | No | Plugin-free <img> in templates |
| Next.js export | Via next/image | Effect-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: autowithout 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 ownwidth/height. - Animating layout properties. Transitioning
height,topormarginregisters as shifts; animatetransformandopacityinstead, 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
widthandheighton 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.
Related
- Parent: Performance Optimization & Core Web Vitals for SSGs — where CLS sits among the vitals.
- Reserving Space for Images and Embeds to Stop Layout Shift — the dimension recipe in full.
- Eliminating Layout Shift From Web Fonts — metric overrides, preloading and the swap window.
- Fixing CLS From Late-Loading Embeds — placeholders for iframes, ads and comments.
- Measuring CLS in the Field With web-vitals.js — real-user collection and attribution.
- Largest Contentful Paint Optimization for Static Sites — the sibling metric your image work also moves.
- Font Loading Strategies for Static Sites — the loading policy behind the swap.