Reserving Space for Images and Embeds
An image with no declared dimensions occupies zero height until its bytes arrive, so every element below it renders in the wrong place and then jumps. This is the single largest source of Cumulative Layout Shift on content sites, and on a static site it is entirely preventable: the generator has the file on disk at build time and knows exactly how big it is.
This guide is the concrete recipe — what to emit for plain images, responsive srcset, art-directed <picture>, and embeds that have no intrinsic size at all — plus the numbers from applying it to a documentation template. It is the media-specific piece of Cumulative Layout Shift Fixes for Static Sites.
Prerequisites
- A static site where images are processed at build time (Astro
astro:assets, Hugo image resources,@11ty/eleventy-img, or a pre-processing step of your own). - Chrome DevTools for the Performance panel's Layout Shifts track, or the Layout Instability script from the parent guide.
- A baseline CLS number per template so you can prove the change worked.
Why the Attributes Still Matter
Browsers compute an intrinsic aspect ratio from the width and height attributes before any CSS is applied. That gives the layout engine a box to reserve during the very first pass — before the image request has even resolved. CSS aspect-ratio cannot do this alone, because a stylesheet arrives after the HTML has begun laying out, and because the property needs at least one known dimension to work from.
The Recipe
1. Plain images
Emit the intrinsic dimensions of the source file and let CSS control the rendered width:
<img src="/img/pipeline.avif" width="1600" height="900"
alt="Build pipeline stages" loading="lazy" decoding="async">
.prose img { max-width: 100%; height: auto; }
height: auto is what keeps the ratio honest once max-width shrinks the image; without it the browser uses the literal height attribute and distorts the picture on narrow screens.
2. Responsive srcset
srcset candidates all share one intrinsic ratio, so a single width/height pair still describes the box:
<img
srcset="/img/hero-480.avif 480w, /img/hero-960.avif 960w, /img/hero-1600.avif 1600w"
sizes="(min-width: 768px) 720px, 100vw"
src="/img/hero-960.avif" width="1600" height="900"
alt="Deployment dashboard" fetchpriority="high">
Generate the candidates from the same source so the ratio cannot drift. Astro's <Picture> does this automatically, as covered in Image Optimization Pipelines in Astro; the equivalent Hugo approach is in Optimizing WebP Images in Hugo Without Plugins.
3. Art-directed picture elements
This is where reservations break. If a mobile <source> is a 1:1 crop and the desktop <source> is 16:9, the box has to change at the breakpoint. Put the dimensions on each <source> so the browser knows the ratio for the media query it matched:
<picture>
<source media="(min-width: 768px)" srcset="/img/wide.avif" width="1600" height="900">
<source srcset="/img/square.avif" width="800" height="800">
<img src="/img/wide.avif" width="1600" height="900" alt="Release timeline">
</picture>
4. Iframes and embeds
An iframe has no intrinsic size, so the wrapper owns the reservation:
.embed { position: relative; width: 100%; aspect-ratio: 16 / 9; }
.embed > iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
For embeds without a fixed ratio — a comment thread, a newsletter form, a status widget — reserve a realistic min-height instead and let the content grow downward from a stable top edge. A wrong-by-40-pixels reservation costs a fraction of the score that a zero-height reservation does.
Which technique applies is decided entirely by what you know at build time, and the decision is small enough to memorise:
The min-height branch feels unsatisfying, but it is worth being precise about how much it buys. A widget that finally renders at 460 px in a container reserved for 420 px moves the content below it by 40 px rather than 460 px — an eleven-fold reduction in the distance fraction, and usually the difference between a failing and a passing score. Estimate from the median observed height in production rather than the smallest possible one, and re-check it when the widget's content changes.
5. Enforce it in the build
Manual discipline decays. Fail the build when an image slips through:
// scripts/check-img-dims.mjs — run after the build, over the generated HTML
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';
let bad = 0;
for (const file of globSync('dist/**/*.html')) {
const html = readFileSync(file, 'utf8');
for (const [tag] of html.matchAll(/<img\b[^>]*>/g)) {
if (!/\bwidth=/.test(tag) || !/\bheight=/.test(tag)) {
console.error(`${file}: ${tag.slice(0, 90)}`);
bad++;
}
}
}
if (bad) {
console.error(`check-img-dims: ${bad} image(s) without dimensions`);
process.exit(1);
}
console.log('check-img-dims: all images carry dimensions');
Wire it into the same CI job that runs your other content gates — the pattern is the same one used for build-time checks in GitHub Actions for Automated SSG Builds.
Measured Impact
Measured on a documentation template with six body images and one video embed, Chrome 4× CPU throttle on a fast 3G profile, median of five runs:
| Change | CLS | LCP | Notes |
|---|---|---|---|
| Baseline (no dimensions) | 0.31 | 2.9 s | Every body image collapses to zero height |
width/height on body images | 0.07 | 2.9 s | Remaining shift is the video embed |
| Aspect-ratio wrapper on the embed | 0.01 | 2.9 s | Residual is a late-swapping web font |
Above + loading="lazy" below fold | 0.01 | 2.4 s | 380 KB fewer bytes on first load |
The LCP row is worth noting: reserving space did not make the page faster, it made it stable. The 0.5 s LCP improvement came from lazy-loading below-the-fold images, which is a separate lever — and one that is only safe once the boxes are reserved.
Pitfalls & Rollback
- Putting rendered size in the attributes. Use intrinsic dimensions. Attributes of
width="640" height="360"on a 1600×900 source still work (same ratio), but a mismatched ratio distorts the image. - Omitting
height: autoin CSS. Withmax-width: 100%alone the browser honours the literal height attribute and squashes the picture on narrow viewports. - One ratio for art-directed sources. If crops differ per breakpoint, each
<source>needs its own dimensions or the box re-reserves at the breakpoint. - Reserving with a spacer element. A JavaScript-inserted spacer runs after paint, which is the thing you are trying to avoid. Reserve in HTML and CSS only.
- Lazy-loading the LCP image. Correct dimensions make lazy loading safe for CLS but
loading="lazy"on the hero still delays LCP — keep the first image eager, as covered in Reducing LCP From Hero Images on Static Sites. - Rollback: every change here is additive markup. Removing the attributes or the wrapper class restores the previous behaviour with no data migration and no cache to purge.
Conclusion
Reserving space is the cheapest performance work available to a static site: no new bytes, no new requests, and a build-time source of truth for every dimension. Emit width and height on every image, give each <source> its own pair when crops differ, wrap embeds in an aspect-ratio box, and enforce it with a build check so the next contributor cannot regress it. Then move on to the remaining causes — fonts and injected content — in Cumulative Layout Shift Fixes for Static Sites.
FAQ
Do I still need width and height if my CSS sets aspect-ratio?
Yes. The HTML attributes are available to the browser before any stylesheet is parsed, so they protect the very first layout pass. The CSS aspect-ratio property is a safety net for cases where a responsive rule changes the rendered width, not a replacement for the attributes.
What values go in width and height for a responsive image?
The intrinsic pixel dimensions of the source file, not the rendered size. The browser only uses the ratio between them, then scales the box to whatever CSS says the width should be, so a 1600 by 900 attribute pair works perfectly for an image displayed at 640 pixels wide.
How do I reserve space for an iframe whose height is unknown?
Give the wrapper a fixed aspect ratio if the content has one, such as 16 by 9 for video, and a min-height otherwise. A slightly wrong reservation still scores far better than none, because the shift is limited to the difference rather than the whole element height.
Does lazy loading cause layout shift?
Only if the element has no reserved space. With correct dimensions, loading="lazy" is safe and saves bytes. Without them, lazy loading makes shifts worse because the image arrives later, after the reader has probably already started reading.
What about images whose size is only known at request time?
Fetch them at build time and record the dimensions in your content data, or serve them through a transform that guarantees a fixed ratio. If neither is possible, wrap them in a box with a declared ratio and accept letterboxing rather than a shift.
Related
- Parent: Cumulative Layout Shift Fixes for Static Sites — the other three causes of shift.
- Fixing CLS From Late-Loading Embeds — placeholders for content scripts inject.
- Eliminating Layout Shift From Web Fonts — the shift that survives after the images are fixed.
- Image Optimization Pipelines in Astro — where the dimensions come from for free.
- Reducing LCP From Hero Images on Static Sites — keeping the first image fast as well as stable.