Fixing CLS with next/image fill and sizes

Cumulative Layout Shift happens when content moves after it first paints — and an image that arrives without reserved space is the classic trigger: the browser lays out the page assuming the image is zero-height, then reflows everything below it once the bytes arrive. next/image is designed to prevent this, but two of its features — fill and the sizes prop — are exactly where teams reintroduce shift. This guide, part of Next.js Image Component Optimization within Framework & Build-Tool Media Integration, shows precisely when to use explicit width/height versus fill, how to size a fill container so it holds its box, and why a wrong sizes still costs you even when layout is stable.

Prerequisite checklist

Why images shift, and how next/image reserves space

An <img> with no dimensions has an intrinsic size of 0×0 until its headers arrive, so the browser gives it no height at layout time. next/image avoids that by always producing a box before the image loads — but it uses two different mechanisms, and picking the wrong one for the layout is what reintroduces shift.

  • Explicit width/height (or a static import). The component sets the <img> width and height attributes; the browser derives an aspect-ratio and reserves a box scaled to the container. This is bulletproof and needs no CSS.
  • fill. The component drops intrinsic dimensions and renders the image position:absolute; inset:0. Now the box comes entirely from the parent — if the parent has no height, the reserved space is zero and the image shifts content exactly as a raw <img> would.
Explicit dimensions vs fill container for CLS-free images Left: an Image with explicit width and height derives an aspect ratio and reserves its own box. Right: a fill Image is absolutely positioned and only reserves space if its parent is positioned and sized with aspect-ratio; an unsized parent collapses to zero height and shifts content. Explicit width / height <Image width={800} height={450}> browser derives 16:9 box box reserved before load CLS = 0 · no CSS needed fill parent: position:relative aspect-ratio: 16 / 9 <Image fill> inside sized box reserved CLS = 0 unsized 0px → SHIFT

The mechanism behind the first bullet is worth stating precisely, because it is where the CSS can undo the fix. Since 2020 every major engine applies a UA rule equivalent to img { aspect-ratio: attr(width) / attr(height); }, so the presence of the two attributes gives the element a default ratio during layout — before a single byte of image data exists. That default only survives if exactly one of the two CSS dimensions is auto. Set width: 100% and leave height unset and the ratio computes the height: correct. Set both width: 100% and height: 100%, or apply a global img { height: auto; width: auto } reset in the wrong order, and the ratio is overridden — the box reverts to zero-height until load, and the attributes you carefully passed do nothing. The single most common “next/image still shifts” report traces back to a CSS reset, not to the component.

fill opts out of that mechanism deliberately. It emits no width/height attributes at all, so there is no ratio to inherit; the element is position: absolute with inset: 0, meaning its used size is defined entirely by the nearest positioned ancestor’s content box. If that ancestor’s height resolves to zero at layout time, the reserved space is zero — the absolute child contributes nothing to its parent’s height, by definition of out-of-flow layout.

Exact solution

Path A — explicit width/height (prefer this)

Whenever the display aspect ratio is fixed and known, pass width and height (or use a static import, which supplies them). The numbers are the intrinsic ratio, not the CSS pixels — style the rendered size with CSS.

import Image from 'next/image';
import portrait from '@/assets/author.jpg'; // static import → dims + blur baked in

// The 3:4 ratio is reserved immediately. CSS controls the on-screen size;
// the browser scales the reserved box to match, so nothing shifts.
<Image
  src={portrait}
  alt="Author portrait"
  sizes="(max-width: 640px) 40vw, 200px"
  style={{ width: '200px', height: 'auto' }}
/>;

Path B — fill with a sized aspect-ratio container

Use fill only when the image must cover a box whose size the markup, not the image, decides — a card cover, a hero that crops. The parent must be positioned and have a height source. aspect-ratio is the cleanest one because it reserves height responsively without magic numbers.

// The wrapper owns the geometry. position:relative anchors the absolute image;
// aspect-ratio reserves height at every width BEFORE the image loads.
<div className="cover">
  <Image
    src="/covers/mountain.jpg"
    alt="Snow-capped ridge at dawn"
    fill
    // object-fit:cover crops to the reserved box instead of stretching.
    style={{ objectFit: 'cover' }}
    // sizes must still describe the box width, or the browser over-fetches.
    sizes="(max-width: 768px) 100vw, 768px"
  />
</div>
.cover {
  position: relative;   /* REQUIRED: fill image is absolute; needs a positioned ancestor */
  aspect-ratio: 16 / 9; /* reserves height at all widths — the anti-CLS mechanism */
  width: 100%;
  /* Fallback for Safari 14 (no aspect-ratio): a padding-top hack.
     @supports keeps it from double-applying on modern browsers. */
}
@supports not (aspect-ratio: 1) {
  .cover { height: 0; padding-top: 56.25%; } /* 9/16 = 56.25% */
}

Warning: aspect-ratio is unsupported in Safari 14. If that tier matters, keep the padding-top fallback above, or prefer Path A on the LCP image — a fill hero can still shift on Safari 14 without it.

Why sizes matters even when layout is stable

sizes does not affect CLS directly — a correct box holds whether or not sizes is right. But a wrong sizes makes the browser pick the wrong srcset width: too large and you over-fetch (wasted bytes, slower LCP); too small and the image renders soft (under-fetch). The rule is that sizes must state the image’s rendered width per breakpoint, matching the CSS that sizes the box.

There is a second, less obvious coupling. When you pass sizes, next/image builds the srcset from the full deviceSizes list; when you omit it, the component falls back to a 1×/2× pair derived from the width prop and emits sizes="100vw". So omitting sizes on a fill image does not just guess wrong — it changes which candidate widths exist at all. The table below measures one real slot: a 360 px catalogue column on a 1440 px viewport at DPR 1, AVIF at quality 70, default deviceSizes.

Declared sizes Candidate the browser picks Transferred Overhead
100vw (or omitted) 1920w 214 KB 5.1×
50vw 828w 61 KB 1.5×
(max-width: 1024px) 50vw, 360px 640w 42 KB baseline
Byte cost of a wrong sizes value Horizontal bar chart. A sizes value of 100vw makes the browser pick the 1920w candidate and transfer 214 kilobytes. 50vw picks the 828w candidate for 61 kilobytes. A sizes value matching the real 360 pixel slot picks 640w for 42 kilobytes, the baseline. AVIF bytes fetched for one 360 px slot — 1440 px viewport, DPR 1 Layout is stable in all three rows; only the byte cost changes. sizes="100vw" picks 1920w 214 KB · 5.1× sizes="50vw" picks 828w 61 KB · 1.5× sizes="… 360px" picks 640w 42 KB · baseline 0 100 KB 214 KB

The same arithmetic drives CSS container queries for dynamic media sizing: once a card’s width is set by its container rather than the viewport, a viewport-relative sizes is guaranteed to be wrong somewhere in the layout.

// Grid: 100vw on phones, 50vw on tablets, a fixed 360px column on desktop.
// If this said sizes="100vw" the desktop request would pull a ~1920px file
// for a 360px slot — 5x too many bytes.
<Image
  src="/grid/item.jpg"
  alt="Catalogue item"
  width={720}
  height={720}
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 360px"
/>;

Verification

1. Lighthouse CLS

# Isolate CLS on a mobile profile. A passing route scores < 0.1; aim for 0.
lighthouse https://localhost:3000/article \
  --only-categories=performance \
  --form-factor=mobile \
  --output=json \
  | jq '.audits["cumulative-layout-shift"].numericValue'

2. Attribute the shift to a specific node in the field

// Lab CLS can be zero while field CLS is not — real users scroll, and a shift
// only counts if it happens outside the 500 ms window after an interaction.
// This logs every shift source so you can name the offending element.
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue;          // ignore user-triggered reflow
    for (const source of entry.sources ?? []) {  // sources[] names the moved node
      console.log(entry.value.toFixed(4), source.node);
    }
  }
}).observe({ type: 'layout-shift', buffered: true }); // buffered:true replays pre-observer entries

3. Highlight layout-shift regions in DevTools

  1. Open DevTools → Rendering panel → enable Layout Shift Regions. Reload; shifting areas flash blue. If an image flashes on load, its box was not reserved.
  2. Open Performance → record a load → find the Layout Shift markers; the event detail names the shifted node and its score contribution. Trace it back to a fill image whose parent lacks aspect-ratio, or a missing width/height.
  3. Throttle the network to Slow 4G to widen the window between layout and image paint — CLS bugs that hide on fast connections become obvious.

Throttling matters because CLS is a timing bug dressed as a layout bug. The shift only scores if it happens after first paint and outside the 500 ms input-exclusion window; on a fast local connection the image often decodes inside the same frame as the surrounding text, so the reflow never registers. The filmstrip below contrasts the two parent configurations across the same three moments of a throttled load.

Load filmstrip: unsized parent versus aspect-ratio parent Three frames at zero, four hundred and twelve hundred milliseconds. In the top row the fill image sits in an unsized div, so the box is zero pixels until the image paints and the following text is pushed down, scoring CLS 0.31. In the bottom row the parent declares aspect-ratio 16 by 9, so a reserved box holds from the first frame and nothing moves when the image paints. 0 ms 400 ms 1200 ms — image paints Same route, Slow 4G, two parent configurations <Image fill> in a plain unsized div image box = 0 px still 0 px — bytes in flight image text pushed down 46 px · CLS 0.31 <Image fill> in a parent with aspect-ratio 16/9 box reserved blur placeholder image nothing moves · CLS 0

Common mistakes

1. fill without a sized parent

Anti-pattern: <div><Image fill /></div> with no positioning or height on the div.

Effect: the absolute image has no box to fill; the container collapses to 0px, and when the image paints it pushes all following content down — a large CLS spike.

Fix: give the parent position: relative and a height source (aspect-ratio, an explicit height, or a flex/grid track that sizes it).

2. Missing sizes on a responsive image

Anti-pattern: omitting sizes so next/image defaults to 100vw.

Effect: no layout shift, but the browser fetches the largest deviceSize for narrow slots — inflating bytes and LCP even though the page looks stable.

Fix: set sizes to the real rendered width per breakpoint; verify the chosen width in the Network panel matches the slot.

3. Wrong aspect-ratio on the container

Anti-pattern: the wrapper declares aspect-ratio: 1 / 1 but the source image is 16:9.

Effect: the reserved box has the wrong shape; with object-fit: cover the image is over-cropped, and if you later switch to contain the letterboxing changes the effective height and shifts neighbours.

Fix: set the container aspect-ratio to the image’s true intrinsic ratio; if crops vary per breakpoint, change the ratio inside the same media queries that drive sizes.

4. Setting loading="lazy" on the LCP image

Anti-pattern: a fill hero with loading="lazy" and no priority.

Effect: the fetch is deferred, so the reserved box stays empty longer; combined with a late-arriving blur-to-image swap this both delays LCP and, on unsized parents, widens the shift window.

Fix: mark the LCP image priority (it implies eager loading and emits fetchpriority="high" — see using fetchpriority to optimize critical media), and reserve loading="lazy" for below-fold media.

5. Reserving the box with a hard-coded height in CSS

Anti-pattern: .cover { position: relative; height: 320px; } instead of an aspect-ratio.

Effect: the box is stable, so CLS is zero — but the reserved shape no longer tracks the image. On a 375 px phone a 16:9 source rendered into a 320 px-tall box is cropped far harder than the design intended, and any breakpoint that changes the column width silently changes the crop. Worse, if a later refactor swaps the fixed height for height: auto, the box collapses and the shift returns.

Fix: express the reservation as a ratio, not a length. aspect-ratio derives height from the current width at every breakpoint, which is exactly the invariant next/image relies on. Reach for a fixed height only when the design genuinely pins the box — a 320 px tall banner strip — and then set object-fit: cover deliberately rather than by accident.