Responsive Images Without Layout Shift
Width, height, srcset, and lazy loading patterns that keep pages stable while images load.
Responsive Images Without Layout Shift
A page that jumps when images arrive feels broken even if the pixels eventually look fine. Most layout shift from images is preventable with a few habits.
Reserve space up front
Always set width and height on <img> (or use CSS aspect-ratio) so the browser can allocate layout before the file downloads. The rendered size can still be fluid; the ratio is what stops the jump.
<img
src="/images/hero.jpg"
srcset="/images/hero-640.jpg 640w, /images/hero-1280.jpg 1280w"
sizes="(max-width: 768px) 100vw, 720px"
width="1280"
height="720"
alt="Hero shot of the project"
loading="lazy"
/>
.prose img {
max-width: 100%;
height: auto;
aspect-ratio: attr(width) / attr(height); /* where supported, or set explicitly */
}
In practice I set intrinsic width/height in HTML to the source dimensions, then let CSS scale with max-width: 100%; height: auto. Modern browsers use those attributes to compute aspect ratio and reserve space—exactly what CLS needs.
For background images in CSS, reserve space on the container instead:
.hero {
aspect-ratio: 16 / 9;
background-image: url('/images/hero.jpg');
background-size: cover;
}
CSS-only backgrounds cannot use srcset the same way, so for LCP-critical heroes I prefer a real <img> (or <picture>) and position it as the visual plane. You get dimensions, responsive sources, and alt text in one place.
SVGs and icons should reserve space too—fixed width/height or a wrapping element with a defined box—so fonts and icons do not shove text after load.
Match srcset to real breakpoints
Generate a small set of widths you actually serve—usually two or three—not a dozen sizes nobody requests. Pair srcset with a sizes attribute that reflects your CSS layout, not a generic 100vw on every image.
A sizes lie is a common CLS-adjacent performance bug: the browser picks a huge file because it thinks the image is viewport-wide, when CSS actually shows it at 320px in a card grid.
Workflow that stays honest:
- Look at the layout in DevTools at mobile, tablet, and desktop widths
- Note the displayed CSS width of the image in each band
- Write
sizesto match those bands - Export 2–3 source widths that cover those displays at 1x/2x as needed
<img
src="/images/card-640.jpg"
srcset="
/images/card-320.jpg 320w,
/images/card-640.jpg 640w,
/images/card-960.jpg 960w
"
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 320px"
width="960"
height="640"
alt="Screenshot of the dashboard"
loading="lazy"
/>
Use <picture> when art direction changes—not only resolution. Cropping a wide hero differently on mobile is an art-direction problem; serving the same crop at multiple widths is a resolution problem. Do not conflate them.
Modern formats (AVIF/WebP) with a JPEG/PNG fallback belong in <picture> or negotiated Accept handling. Format gains help bytes; they do not replace width/height for layout stability.
Lazy load below the fold
loading="lazy" on off-screen images saves bandwidth without hurting LCP on the hero. Keep the largest above-the-fold image eager so the first paint stays fast.
Rules of thumb:
- Hero / LCP image —
loading="eager"(or omit lazy), discoverable early in HTML, properly sized - Below the fold —
loading="lazy" - Carousels — lazy slides that are not visible; be careful with preload tricks that fight lazy
fetchpriority="high"— consider on the single LCP image when competing with other requests
Lazy loading does not fix missing dimensions. A lazy image without width/height still shifts layout when it finally paints. Fix space first, then defer the download.
For CSS background-image galleries, lazy behavior needs Intersection Observer or similar—native loading does not apply. Prefer <img> in content whenever you can.
Open Graph and social preview images are a separate concern—they do not affect CLS on your page—but the in-article figures that do should follow the same width/height discipline whether they come from markdown or a CMS. Consistency across templates matters more than one perfect hero.
Watch for the other CLS culprits around images
Images are the usual suspect, but related shifts sneak in:
- Ads or embeds injected above content without reserved height
- Web fonts swapping metrics after image layout settled (less common, still real)
- Dynamic banners (“cookie consent”, “new post”) inserted at the top after load
- Aspect ratios that change between skeleton and final media
Reserve space for those embeds the same way you do for photos. If you must inject UI, prefer inserting below the viewport or in a slot that already has height.
In Chrome Performance / Experience tools, look at CLS contributions and confirm the shifting node is the image (or its wrapper). Fixing a unrelated font issue will not help if the hero still lacks dimensions.
On content sites I bake dimensions into the template or markdown pipeline so authors cannot ship a bare <img src> without width and height. A lint rule or a remark/rehype check is enough; the point is to make the stable default automatic rather than a memory test on every PR. Once the habit is structural, layout shift from images stops being a recurring fire drill.
Wrap-up
Stable image layout is mostly explicit dimensions plus honest srcset/sizes, with lazy loading reserved for below-the-fold media. Fix those once per template—post body, card grid, project hero—and CLS from images largely disappears. The page should feel finished while pixels are still arriving, not rearrange when they do.