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

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.

// 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. 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.

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"), and reserve loading="lazy" for below-fold media.