Preventing CLS When Swapping a Placeholder for the Real Image

The constraint is narrow and absolute: between the frame that paints the placeholder and the frame that paints the decoded photograph, the border box of the image element — and of every ancestor whose size depends on it — must be numerically identical. Everything else about the swap is a matter of taste. Progressive Rendering and Image Placeholders covers which placeholder to ship and what it costs; this page is about the forty to eighty milliseconds in which you exchange one for the other without the layout instability API noticing.

The failure is easy to miss in development because it needs three things to coincide: a real network delay, a viewport where the image is not the last element on the page, and a placeholder whose geometry is not already the photo’s geometry. On a warm local cache all three vanish and the swap looks perfect. In the field the same code reports a 0.18 Cumulative Layout Shift on mobile and nothing on desktop, because the mobile grid stacks to one column and every subsequent card absorbs the displacement.

Prerequisite checklist

The geometry contract

A layout shift happens when a box that has already been painted moves. The only way to guarantee it does not move is to make its position computable from HTML and CSS alone, before a single image byte has arrived, and to keep that computation stable when the bytes do arrive. Three different mechanisms can supply that geometry, and they do not compose well:

The width/height attribute mapping. When both attributes are present on an <img>, the UA stylesheet applies aspect-ratio: attr(width) / attr(height) to the element. This is a default, not an override: it only produces a reserved box while the used height is auto and the used width resolves from the containing block. The near-universal img { max-width: 100%; height: auto; } reset preserves it. A layout rule that sets height: 100% on the image — extremely common in card components — silently discards it, and the box collapses to zero until the photo decodes.

An explicit aspect-ratio on a wrapper. This one is unconditional. The wrapper’s height is derived from its own resolved width and never consults its children, so nothing the image does at decode time can change it.

The image’s intrinsic ratio, discovered at decode. This is the one you are trying to avoid. It arrives asynchronously, and if it is the only source of geometry the box is 0 px tall until it lands.

The robust construction picks the second mechanism and then removes the third from the equation entirely by taking the image out of flow. An absolutely positioned image that fills its containing block contributes nothing to layout no matter what its intrinsic dimensions turn out to be — a 32×18 low-quality placeholder and a 4000×2250 photograph produce identical boxes because neither is consulted.

Who Owns the Reserved Geometry, and Where It Leaks Four nested rectangles represent the scroll container, the grid track, the media wrapper carrying an aspect ratio, and the image border box positioned absolutely inside it. Three callouts on the right mark the leak points: a missing width and height pair on the image, a wrapper whose height comes from its content, and an unstable scrollbar gutter that narrows the grid track when the page grows. Geometry is computed outside-in — the first box that cannot resolve without pixels is the one that shifts scroll container — width depends on the scrollbar grid track — 1fr of a resolved container width .media — aspect-ratio: 1600 / 900 height derives from its own width, never from children <img> — position: absolute; inset: 0 out of flow: intrinsic ratio cannot vote placeholder layer 32×18 → photo layer 1600×900 → same box, both frames Leak 1 — no width/height attributes and no wrapper ratio: the box is 0 px tall until the decode reports 1600×900 Leak 2 — content-driven wrapper height an in-flow placeholder sets the height, then is removed and the wrapper collapses Leak 3 — unstable scrollbar gutter the grid grows past the viewport, a 15 px scrollbar appears, every track narrows Leaks 1 and 2 move one card. Leak 3 moves every card on the page at once, which is why grid swaps score worse than hero swaps.

Leak 3 deserves special attention because it is invisible in a single-image test page. A gallery that lazily swaps forty placeholders will, at some point during the scroll, cross the threshold where the document becomes taller than the viewport. The classic scrollbar takes 15 px from the inline size of the scroll container, every grid track narrows, and every already-painted card moves horizontally. The individual distance is tiny but the impact fraction is the entire viewport, and the entry lands in whatever session window is open.

The swap that cannot shift

One wrapper, one absolutely positioned image, one compositor-only transition. Every declaration below is load-bearing; the comments say which invariant it protects.

<!-- Root-level guard: reserve the scrollbar gutter for the whole document so that
     a page which grows past the viewport height never re-flows horizontally.
     'stable' always reserves; 'stable both-edges' keeps the content centred. -->
<style>
  :root { scrollbar-gutter: stable; }

  .media {
    /* --ratio is templated from the build manifest as the asset's TRUE intrinsic
       dimensions, e.g. "1600 / 900" or "4032 / 3024". Never a hardcoded constant:
       a 3:2 photo in a 16:9 box does not shift, but it recrops visibly on swap. */
    aspect-ratio: var(--ratio);
    /* Width comes from the layout context (grid track, column, 100%). Height is
       derived from width × ratio, so the box is fully resolved at first style
       recalc — before any image byte exists. */
    width: 100%;
    position: relative;          /* containing block for the absolute layers */
    overflow: hidden;            /* clips the blur bleed; also creates a BFC     */
    background-color: var(--ph); /* dominant colour: the zero-decode first paint */
    contain: layout paint;       /* nothing inside can affect outside geometry   */
  }

  /* Placeholder layer. Absolute, so it has no flow contribution to lose when it
     is faded out. filter: blur() on an absolutely positioned box repaints only
     that layer — it never reflows the wrapper. */
  .media::before {
    content: "";
    position: absolute;
    inset: -4%;                  /* over-inset so the blur has no soft border    */
    background-image: var(--lqip);
    background-size: cover;
    filter: blur(16px);
    transform: scale(1.04);      /* compositor-only; hides the blur falloff      */
    transition: opacity 240ms linear;
  }

  /* The photo. inset:0 + object-fit means the intrinsic ratio is used only to
     decide how pixels are painted INSIDE a box that is already fixed. */
  .media > img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    opacity: 0;
    /* Transition ONLY opacity. Both opacity and transform are handled on the
       compositor thread and are excluded from layout entirely — animating them
       cannot produce a layout-shift entry at any frame rate. */
    transition: opacity 240ms linear;
  }

  .media[data-state="ready"] > img      { opacity: 1; }
  .media[data-state="ready"]::before    { opacity: 0; }

  /* If the swap script never runs, the photo must still be visible. Gating
     initial visibility on JavaScript is how pages ship a permanent blur. */
  @media (scripting: none) {
    .media > img   { opacity: 1; transition: none; }
    .media::before { opacity: 0; }
  }

  /* Below-the-fold sections: contain-intrinsic-size supplies a stand-in height
     for skipped subtrees. 'auto' makes the browser remember the last rendered
     size, so scrolling back up does not re-shift. */
  .gallery { content-visibility: auto; contain-intrinsic-size: auto 1200px; }
</style>

<!-- width/height stay on the element even though the wrapper owns the geometry:
     they are the no-CSS fallback and they keep the ratio correct if a future
     refactor drops the absolute positioning. -->
<figure class="media"
        style="--ratio: 1600 / 900;
               --ph: #3f5b46;
               --lqip: url('data:image/webp;base64,UklGRhwA…')">
  <img src="/media/hero-1600.avif"
       srcset="/media/hero-800.avif 800w, /media/hero-1600.avif 1600w"
       sizes="(max-width: 800px) 100vw, 1200px"
       width="1600" height="900"
       decoding="async"
       fetchpriority="high"
       alt="Sunrise over the harbour terminal">
</figure>

<script>
  // decode() resolves only once the frame is rasterised and paintable. Flipping
  // opacity on 'load' instead can animate into a frame that does not exist yet,
  // which shows as a flash — not a shift, but the same swap defect class.
  const ready = (img) => {
    const box = img.closest('.media');
    const finish = () => { box.dataset.state = 'ready'; };
    if (img.decode) img.decode().then(finish, finish);   // errors must still reveal
    else finish();
  };

  for (const img of document.querySelectorAll('.media > img')) {
    // complete && naturalWidth covers images already in the HTTP cache at parse
    // time; those never fire 'load' and would otherwise stay at opacity 0.
    if (img.complete && img.naturalWidth > 0) ready(img);
    else img.addEventListener('load',  () => ready(img), { once: true });
    img.addEventListener('error', () => ready(img), { once: true });
  }
</script>

Warning: contain: layout paint is the belt-and-braces line and it has a cost — it forces the wrapper to be a containing block for fixed-position descendants and clips paint at its bounds. If you have a hover overlay that deliberately escapes the card, drop paint and keep layout.

Which transitions can move layout

There are exactly four state changes in the life of a swapped image, and only two of them are capable of producing a layout-shift entry. Knowing which is which turns CLS debugging from guesswork into elimination.

Swap State Machine — Compositor-Only vs Layout-Affecting Transitions Four states run left to right: box reserved, placeholder painted, bytes complete, and photo composited. The three transitions between them are labelled as compositor-only and safe. Below each transition sits a red box naming the mistake that converts it into a layout-affecting change: no reserved ratio at the first transition, an in-flow placeholder removed at the second, and a transitioned height at the third. 1 · box reserved style recalc, no bytes 2 · placeholder up background paint only 3 · bytes complete load fires, not painted 4 · composited CLS 0.000 paint decode opacity Correct path — every transition is paint or composite, never layout no ratio reserved box is 0 px tall until the first decode, then everything below drops LAYOUT in-flow placeholder removed with display: none — its 32×18 box vanishes from the flow LAYOUT height transitioned transition: all animates height — one shift entry per animation frame LAYOUT × 14 scrollbar appears document outgrows the viewport, tracks narrow by 15 px LAYOUT Transitions 1→2 and 3→4 are the swap itself. If a shift is recorded there, the geometry contract is broken, not the animation. A shift recorded at 2→3, with no visual change on screen, is almost always the scrollbar or a sibling font swap — not this image. Compositor-only property set for animation: opacity, transform, filter, backdrop-filter. Everything else is a repaint or a reflow.

The LAYOUT × 14 figure in the third box is not hyperbole. The layout instability spec emits one entry per animation frame in which an already-painted node changes position, so a 240 ms transition on height at 60 fps produces roughly fourteen entries whose values sum into the same session window. This is why a page can score 0.31 while every individual frame moves only a few pixels.

How a swap shift is scored

The score of a single entry is impact fraction × distance fraction. The impact fraction is the union of the before-and-after areas of all unstable elements, divided by the viewport area. The distance fraction is the greatest single-axis movement of any unstable element, divided by the larger of viewport width and height. Both are capped at 1, so one entry can never exceed 1.0, and the reported CLS is the largest sum over any 5-second session window (with a 5-second cap and a 1-second gap rule).

Two consequences matter for image swaps. First, an image low on a long page that pushes only its own caption produces a negligible score, while the same defect on a hero above three paragraphs of copy produces a large one — the defect is identical, the impact fraction is not. Second, Blink does not record movement below a small threshold of roughly three physical pixels, which is exactly why sub-pixel ratio rounding (1600/900 versus 16/9 on a 373 px track) usually costs you nothing, and why a 15 px scrollbar always does.

Scoring One Swap Shift: Impact Fraction Times Distance Fraction On the left, a phone-shaped viewport 390 by 844 with a hero image box at the top and three paragraphs beneath. The paragraphs are shown in their before and after positions, 328 pixels apart. On the right, the arithmetic: the union of moved regions covers 0.62 of the viewport, the movement distance of 328 pixels divided by the 844 pixel viewport height gives 0.39, and the product is a score of 0.24. One entry: hero box grows from 0 to 328 px when the photo finally supplies a ratio before — 390 × 844 img — 0 px tall after — everything below moves img — 328 px tall 328 px impact fraction union of before + after regions = 524 px of 844 px height → 0.62 distance fraction greatest movement on one axis = 328 px ÷ max(390, 844) → 0.39 entry value = 0.62 × 0.39 = 0.24 Good threshold is 0.10 for the whole session window — one swap blows it. Reserving the box makes both factors 0.

Verification

1. Attribute every shift to the node that moved

Lighthouse reports a number; this reports a culprit. Load it before any image markup so buffered: true has the full history, and correlate each entry against recent image load events — a shift within 150 ms of a decode is almost certainly the swap.

// Paste in the Console, or ship behind a ?debug=cls flag in staging.
const loads = [];   // rolling log of recent image completions
new PerformanceObserver(l => {
  for (const e of l.getEntries()) {
    if (e.initiatorType === 'img') loads.push({ url: e.name, t: e.responseEnd });
  }
}).observe({ type: 'resource', buffered: true });

let total = 0;
new PerformanceObserver(l => {
  for (const e of l.getEntries()) {
    if (e.hadRecentInput) continue;          // excluded from the metric by spec
    total += e.value;
    const near = loads.filter(r => Math.abs(r.t - e.startTime) < 150);
    console.groupCollapsed(
      `shift ${e.value.toFixed(4)} @ ${Math.round(e.startTime)} ms  (running ${total.toFixed(4)})`
    );
    for (const s of e.sources) {
      // previousRect/currentRect are the before and after border boxes. If the
      // height differs, geometry was never reserved. If only y differs, the
      // culprit is a SIBLING above this node, not the node itself.
      console.log(s.node, {
        dy: Math.round(s.currentRect.y - s.previousRect.y),
        dh: Math.round(s.currentRect.height - s.previousRect.height),
        dx: Math.round(s.currentRect.x - s.previousRect.x)
      });
    }
    if (near.length) console.log('image completions within 150 ms:', near.map(r => r.url));
    console.groupEnd();
  }
}).observe({ type: 'layout-shift', buffered: true });

A dx of exactly -15 (or -17 on Windows) on many nodes at once is the scrollbar, not the image. A dh equal to the photo’s rendered height is a missing reserved box. A dy with dh === 0 means the node is innocent and something above it grew.

2. Watch the swap frame by frame in DevTools

Open Rendering (⌘⇧P → “Show Rendering”) and enable Layout Shift Regions — offending areas flash blue at the moment they move, which localises the defect faster than any log. Then record a Performance trace with Slow 4G and cache disabled: the Experience track shows a red Layout Shift marker whose summary lists Moved from / Moved to rectangles and the score contribution. Line that marker up against the Decode Image entry on the main thread; if they are adjacent, the decode supplied geometry the layout was still waiting for.

3. Assert border-box equality in CI

The strongest regression test is not a score threshold — it is a structural claim: the image’s box is the same before and after loading. This fails deterministically, unlike a CLS number that drifts with machine load. Pair it with a byte budget from Lighthouse CI budget enforcement for image weight so a fix for one never silently regresses the other.

// npm i puppeteer — asserts geometry stability across the swap.
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
  const page = await browser.newPage();
  await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 3 });

  // Block image bytes so the page renders in its placeholder-only state.
  await page.setRequestInterception(true);
  page.on('request', r => (r.resourceType() === 'image' ? r.abort() : r.continue()));
  await page.goto('https://example.com/', { waitUntil: 'domcontentloaded' });

  const before = await page.$$eval('.media > img',
    els => els.map(e => { const r = e.getBoundingClientRect();
                          return [Math.round(r.width), Math.round(r.height)]; }));

  // Now let images through and reload.
  await page.setRequestInterception(false);
  await page.reload({ waitUntil: 'networkidle0' });
  const after = await page.$$eval('.media > img',
    els => els.map(e => { const r = e.getBoundingClientRect();
                          return [Math.round(r.width), Math.round(r.height)]; }));

  const bad = before.filter((b, i) => b[0] !== after[i][0] || b[1] !== after[i][1]);
  if (bad.length) { console.error('unreserved boxes:', bad, after); process.exitCode = 1; }
  else console.log(`${before.length} media boxes stable across the swap`);
  await browser.close();
})();

For the metric-level gate alongside it, lighthouse URL --only-audits=cumulative-layout-shift,unsized-images --preset=desktop --output=json keeps the run under two seconds and fails loudly on any image that reaches the DOM without dimensions.

Common mistakes

1. Ratio laundering — a hardcoded box for a variable library

A design system that sets aspect-ratio: 16 / 9 on every card is shift-free and still wrong: a 3:2 photograph swapped into a 16:9 box recrops the instant object-fit: cover takes over from the placeholder’s own framing, and faces near the top of the frame get sliced. The shift score stays at zero while the visual defect is obvious.

/* WRONG — the box is stable but it is not the photo's box. */
.card-media { aspect-ratio: 16 / 9; }
/* CORRECT — the true ratio travels with the asset from the build manifest,
   with the design-system value as a fallback for legacy records. */
.card-media { aspect-ratio: var(--ratio, 16 / 9); }

If the design genuinely requires a fixed crop, crop at build time so the delivered asset is 16:9 and the placeholder is derived from the same crop. Art-directed crops per breakpoint belong in the picture element, where each <source> can carry its own width/height.

2. Two elements taking turns in the flow

The most common blur-up implementation renders the placeholder and the photo as siblings and hides one of them. Because both are in flow, the handover is a layout operation no matter how carefully it is styled.

<!-- WRONG: removing the placeholder collapses the wrapper for one frame. -->
<div class="media">
  <img class="ph"    src="data:image/webp;base64,…" alt="">
  <img class="photo" src="/hero.avif" alt="Hero" hidden>
</div>
<!-- CORRECT: one box, two absolutely positioned layers, opacity handover.
     Neither layer participates in flow, so neither can change the height. -->
<div class="media" style="--ratio: 1600 / 900">
  <img src="/hero.avif" width="1600" height="900" alt="Hero">
</div>

3. transition: all on the media wrapper

Utility-first stylesheets frequently apply a blanket transition to card containers. When the swap changes a class, every animatable property that differs between the two states animates — including height if it is anything other than derived. The result is a train of small layout shifts rather than one big one, which is harder to spot and scores just as badly.

/* WRONG — height, padding and border-width are all in the layout group. */
.media { transition: all 240ms ease-out; }
/* CORRECT — name the compositor-only properties explicitly. */
.media > img  { transition: opacity 240ms linear; }
.media::before{ transition: opacity 240ms linear; }

4. Forgetting that lazy images swap during a scroll

Above-the-fold swaps happen while the user is still reading. Below-the-fold swaps happen while the user is scrolling, and a shift during a scroll is the most disorienting version of the defect there is. loading="lazy" does not create the problem, but it moves it into the worst possible moment, so the geometry contract matters more for a lazy grid than for a hero. Reserve boxes for every card before the native lazy loading threshold releases the fetch, and give the containing section a contain-intrinsic-size so content-visibility: auto subtrees do not resize as they enter.

5. Reserving the box but not the scrollbar

A gallery that passes every single-image test still fails as a page when the accumulated growth crosses the viewport height and a classic scrollbar appears. Every fixed-width layout narrows by 15–17 px in one frame, producing an entry whose impact fraction is the whole viewport.

/* WRONG — nothing reserves the gutter; the first overflow reflows the page. */
html { overflow-y: auto; }
/* CORRECT — reserve the gutter up front on the scroll container.
   Overlay scrollbars (most mobile, macOS by default) ignore this and cost
   nothing, so it is safe to apply unconditionally. */
:root { scrollbar-gutter: stable; }

Tradeoff: on short pages that never overflow, scrollbar-gutter: stable permanently narrows the content area by the scrollbar width on platforms with classic scrollbars. Use stable both-edges if the resulting off-centre content is visible in your layout, and prefer applying it to the specific scroll container rather than the root when only one region scrolls.