Fixing CLS From Images Without Dimensions in Markdown
 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
| Measure | Value | Tool |
|---|---|---|
| CLS p75, docs pages, mobile | 0.22 | web-vitals RUM |
| CLS, same pages, Lighthouse lab | 0.04 | Lighthouse 12 |
| Images in Markdown without dimensions | 5,380 of 5,420 | HTML scan |
| Largest shift source on poor views | img 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.
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.
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
| Measure | Before | After | Tool |
|---|---|---|---|
| Images without dimensions | 5,380 | 0 | CI scan |
| CLS p75, docs, mobile | 0.22 | 0.04 | web-vitals RUM, 4 weeks |
| Views with CLS > 0.1 | 39% | 5% | web-vitals RUM |
| Image bytes per page (median) | 1.2 MB | 410 KB | DevTools (side effect of processing) |
| Build time | 2 min 10 s | 3 min 05 s cold, 2 min 20 s warm | CI logs |
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.
Related
- Parent: Cumulative Layout Shift Fixes for Static Sites — all common CLS causes.
- Reserving Space for Images and Embeds — the same principle for embeds.
- Responsive Images with srcset in Eleventy — the image pipeline in Eleventy.
- Lazy-Loading Images Without Hurting LCP — combining dimensions with lazy loading.
- Enforcing Alt Text for Markdown Images at Build Time — the other Markdown image check.
Filed underFixes