Fixing CLS From Images Without Dimensions in Markdown

![Deploy settings](./deploy-settings.png) produces <img src="deploy-settings.png" alt="Deploy settings"> — no width, no height. The browser lays out the page with the image taking no vertical space, downloads it, discovers it is 1600×900, and pushes every paragraph below it down by the rendered height. On a documentation page with a screenshot every few paragraphs, a reader who starts scrolling before images load feels the page jump repeatedly. Field CLS shows it; lab tests often do not, because images load quickly on a fast connection.

The fix is mechanical: every image needs its intrinsic dimensions in the HTML. On a static site those are known at build time, so the generator can add them to every Markdown image. This guide does it for Astro, Eleventy and Hugo on a 2,200-page product docs site with about 5,400 Markdown images, then adds a check so new images cannot ship without dimensions. It belongs to Cumulative Layout Shift Fixes for Static Sites; the broader treatment of reserved space is in Reserving Space for Images and Embeds.

Symptoms

MeasureValueTool
CLS p75, docs pages, mobile0.22web-vitals RUM
CLS, same pages, Lighthouse lab0.04Lighthouse 12
Images in Markdown without dimensions5,380 of 5,420HTML scan
Largest shift source on poor viewsimg in article (78%)web-vitals attribution

The gap between field and lab is typical: Lighthouse loads above-the-fold images quickly and does not scroll, while real readers on slower connections scroll into images that are still loading.

Sequence of layout for an image without width and height Three actors, the reader, the browser and the image server. The browser lays out the article with the image box at zero height. The reader starts reading and scrolling. The image's first bytes arrive and the browser learns it is 1600 by 900. The browser lays out the image at its rendered height of 380 pixels and moves every element below it down, which counts as a layout shift. Why a dimensionless image shifts the page Reader Browser Image server layout: img height 0 reads, scrolls header bytes: 1600×900 relayout: content moves 380 px
With width and height attributes, the browser reserves the 380 pixels in the first layout and nothing moves.

The Fix in Astro

Astro already adds dimensions to images it processes. A Markdown image with a relative path (./deploy-settings.png) is processed by astro:assets, which reads the file and writes width and height — so in Astro the problem usually comes from images referenced by absolute path from public/ (/images/deploy.png), which Astro does not process. The site had 3,100 of those, left over from a migration.

Moving the images next to their content and rewriting the paths made Astro process them:

# for each Markdown file, copy referenced /images/... into the file's folder and rewrite the link
node scripts/localise-images.mjs src/content/docs

For images that must stay in public/, a rehype plugin can read their dimensions with image-size at build time and add the attributes:

import { visit } from 'unist-util-visit';
import { imageSize } from 'image-size';
export default function rehypeImgSize() {
  return (tree) => visit(tree, 'element', (node) => {
    if (node.tagName !== 'img' || node.properties.width) return;
    const src = String(node.properties.src || '');
    if (!src.startsWith('/')) return;
    const { width, height } = imageSize(`public${src}`);
    Object.assign(node.properties, { width, height });
  });
}

The Fix in Eleventy

Eleventy's Markdown output contains plain <img> elements. The @11ty/eleventy-img transform plugin processes every <img> in the output HTML, adds dimensions and generates responsive formats:

import { eleventyImageTransformPlugin } from '@11ty/eleventy-img';
eleventyConfig.addPlugin(eleventyImageTransformPlugin, {
  formats: ['avif', 'webp', 'auto'],
  widths: [480, 960, 1440, 'auto'],
  htmlOptions: { imgAttributes: { loading: 'lazy', decoding: 'async' } },
});

It writes width and height from the source image and wraps each image in <picture>. Images that should load eagerly — the first image on a page, if it is the LCP element — get eleventy:ignore or explicit loading="eager" in their HTML.

The Fix in Hugo

Hugo's image render hook sees every Markdown image and, for page resources, knows their dimensions:

{{/* layouts/_default/_markup/render-image.html */}}
{{- $img := .Page.Resources.GetMatch .Destination -}}
{{- with $img -}}
  {{- $w := .Resize "960x webp q80" -}}
  <img src="{{ $w.RelPermalink }}" width="{{ $w.Width }}" height="{{ $w.Height }}"
       alt="{{ $.Text }}" loading="lazy" decoding="async">
{{- else -}}
  {{- with resources.Get .Destination -}}
    <img src="{{ .RelPermalink }}" width="{{ .Width }}" height="{{ .Height }}" alt="{{ $.Text }}" loading="lazy">
  {{- else -}}
    {{- warnf "%s: image %s has no dimensions" $.Page.File.Path $.Destination -}}
    <img src="{{ .Destination | safeURL }}" alt="{{ .Text }}" loading="lazy">
  {{- end -}}
{{- end -}}

Page bundle images resolve through .Page.Resources, shared ones through resources.Get in assets/, and anything else — external URLs — produces a build warning.

How each generator adds width and height to Markdown images A matrix of three generators against three image locations. In Astro, relative images are processed automatically, public folder images need a rehype plugin, and external images need fetching. In Eleventy, the image transform plugin handles local images and can fetch remote ones with caching. In Hugo, page bundle and assets images get dimensions in a render hook, and external images produce a warning. Where dimensions come from, per generator Generator Local images public/static images External URLs Astro astro:assets automatic rehype + image-size fetch at build Eleventy image transform plugin image transform plugin remote with cache Hugo render hook (bundle) render hook (assets) warn, fetch manually
Local images are solved by configuration everywhere; external images are the remaining work.

External Images

About 40 images on the site were hot-linked from external sources — vendor logos, a few screenshots on other hosts. Their dimensions are unknown at build time. They were downloaded into the repository where licences allowed, and the remaining 9 were wrapped in a container with an explicit aspect-ratio matching the image, measured once by hand. Eleventy Image can also fetch remote images during the build and cache them for a day (cacheOptions: { duration: '1d' }), which keeps them up to date without re-downloading on every build.

The CSS That Makes Dimensions Responsive

Dimensions must not make images rigid. The site's base styles already had this; it is worth checking it exists:

img { max-width: 100%; height: auto; }

With height: auto, the browser uses the width and height attributes only to compute the aspect ratio and reserves rendered width × (height / width) of space.

Check It in CI

A post-build scan fails on any <img> inside article content without both attributes:

node -e "
const fs=require('fs'),glob=require('glob');let bad=0;
for (const f of glob.sync('dist/**/*.html')) for (const m of fs.readFileSync(f,'utf8').matchAll(/<img\b[^>]*>/g))
  if (!/\bwidth=/.test(m[0])||!/\bheight=/.test(m[0])) { bad++; if (bad<20) console.log(f, m[0].slice(0,120)); }
process.exit(bad?1:0)"

It runs in 2 seconds and fails the pull request that adds an image without dimensions, which in practice means an external image or an image path the pipeline does not process.

Images Added by Writers Later

The fix has to hold as writers add screenshots every week. In Astro and Hugo the pipeline handles any image placed next to its content automatically, so the only rule writers need is "put images in the page's folder, not in public/" — which the contributing guide states with an example, and which the CI scan enforces by failing on dimensionless images.

Measured Impact

MeasureBeforeAfterTool
Images without dimensions5,3800CI scan
CLS p75, docs, mobile0.220.04web-vitals RUM, 4 weeks
Views with CLS > 0.139%5%web-vitals RUM
Image bytes per page (median)1.2 MB410 KBDevTools (side effect of processing)
Build time2 min 10 s3 min 05 s cold, 2 min 20 s warmCI logs
Field CLS at the 75th percentile and share of poor views before and after Paired bars. CLS at the 75th percentile on mobile docs pages went from 0.22 to 0.04. The share of views with CLS above 0.1 went from 39 to 5 percent. Field CLS before and after Before After CLS p75 0.22 0.04 Views with CLS > 0.1 (%) 39 5
web-vitals RUM, four weeks before and four weeks after the change.

Processing every image added about a minute to cold builds, mostly recovered with the image cache restored in CI, as in Caching Processed Images Between Builds. The 66% reduction in image bytes came free with the processing.

Lighthouse's lab CLS, which had looked fine before, did not change measurably; the improvement was entirely in the field, where real connections and scrolling had exposed the problem.

Pitfalls & Rollback

  • Absolute paths in Astro. Images in public/ are not processed; move them or add a plugin.
  • Fixed dimensions without height: auto. Images become stretched or rigid.
  • Relying on lazy loading. It moves the shift later; it does not prevent it.
  • Forgetting external images. Download them or reserve their aspect ratio.
  • Rollback: each generator change is one plugin or hook; removing it restores plain <img> output, and the CI scan will report it.

Conclusion

Markdown images shift pages because Markdown has no syntax for dimensions, so the generator must add them. Astro does it for processed images, Eleventy's image transform does it for all, and Hugo does it in an image render hook; external images need downloading or an explicit aspect ratio. Adding dimensions to 5,380 images took mobile CLS on docs pages from 0.22 to 0.04, and a 2-second CI scan keeps new images from regressing it.

FAQ

Why do Markdown images cause layout shift?

Markdown image syntax has no width or height, so the generated img element has none either. The browser does not know the image's size until it downloads enough of the file, so it lays out the text with zero height for the image, then moves everything below when the image arrives. With width and height attributes the browser reserves the right space from the first frame.

Do width and height attributes make images fixed-size?

No. With CSS such as max-width 100 percent and height auto, the attributes only provide the aspect ratio, and the image scales responsively. Modern browsers use the attributes to compute an aspect-ratio for the box before the image loads, which is what prevents the shift.

What about images from external URLs in Markdown?

Their dimensions are not known at build time without fetching them. Either download and process them during the build, which also lets you optimise them, or fetch only their headers to read dimensions with a library such as probe-image-size, caching the results between builds. Unknown external images are a common remaining cause of shift.

Does lazy loading prevent this layout shift?

No. Lazy loading delays when the image downloads, but without dimensions the space is still zero until it arrives, so the shift happens later, often while the reader is scrolling, which still counts toward CLS. Lazy loading and dimensions solve different problems and should be used together.

Filed underFixes