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>widthandheightattributes; the browser derives anaspect-ratioand reserves a box scaled to the container. This is bulletproof and needs no CSS. fill. The component drops intrinsic dimensions and renders the imageposition: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.
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 |
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
- Open DevTools → Rendering panel → enable Layout Shift Regions. Reload; shifting areas flash blue. If an image flashes on load, its box was not reserved.
- 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
fillimage whose parent lacksaspect-ratio, or a missingwidth/height. - 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.
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.
Related
- How to calculate optimal sizes attribute values — derive the per-breakpoint widths the sizes prop needs
- Next.js Image Component Optimization — fill, sizes, priority, and the rest of the component API
- Next.js Image Custom Loader for a CDN — route the same CLS-safe images through an image CDN
- Mastering srcset and sizes for responsive layouts — the responsive selection model behind next/image
- Framework & Build-Tool Media Integration — how other frameworks reserve layout space to prevent CLS