Eliminating CLS From CMS-Driven Media
Layout shift in a decoupled front end almost always traces back to a single structural gap: the component that renders editor-supplied media does not know how big that media will be.
When to Use This Approach
Apply this when:
- Your CLS p75 is above 0.1 on article or landing templates while product and listing pages are stable.
- The layout-shift elements reported by Lighthouse are images, iframes, or promo blocks rather than navigation or fonts.
- Editors can insert media into rich text, which means the front end receives markup nobody reviewed.
Where the Space Goes Missing
An image with no declared dimensions occupies zero height until its bytes arrive, so every element beneath it renders at the wrong position and then jumps. The diagram traces the same page through both paths.
Implementation Steps
Step 1: Capture the elements that actually shift
// rum-cls.js
import { onCLS } from 'web-vitals/attribution';
onCLS(({ value, attribution }) => {
navigator.sendBeacon('/rum/cls', JSON.stringify({
value,
largestShiftTarget: attribution.largestShiftTarget,
largestShiftValue: attribution.largestShiftValue,
path: location.pathname,
}));
});
Validation: Group by largestShiftTarget. A handful of selectors will account for nearly all of the score, and they are almost always inside the rich-text container.
Step 2: Make dimensions mandatory in the asset schema
The front end can only reserve space if the CMS gives it numbers. Enforce that at the content model, not in the component.
type MediaAsset {
url: String!
alt: String!
width: Int! # required — not Int
height: Int! # required — not Int
blurHash: String
}
Validation: Query for assets missing dimensions before shipping the constraint, and backfill them from the origin files rather than making the field optional.
Step 3: Reserve space at the component boundary
Every path from CMS media to the DOM must pass through one component that applies the ratio. If a second path exists, the problem returns within a release.
// components/CmsImage.jsx
export function CmsImage({ asset, sizes = '100vw', priority = false }) {
const ratio = asset.width + ' / ' + asset.height;
return (
<span className="cms-media" style={reserve(ratio)}>
<img
src={asset.url}
alt={asset.alt}
width={asset.width}
height={asset.height}
sizes={sizes}
loading={priority ? 'eager' : 'lazy'}
fetchPriority={priority ? 'high' : 'auto'}
decoding="async"
/>
</span>
);
}
const reserve = (ratio) => ({ display: 'block', aspectRatio: ratio, width: '100%' });
/* The wrapper holds the box; the image fills it once decoded. */
.cms-media { position: relative; overflow: hidden; }
.cms-media > img { width: 100%; height: 100%; object-fit: cover; display: block; }
Validation: Disable images in DevTools and reload. Every media slot should still occupy its final height.
Step 4: Rewrite rich-text media through the same component
Rich text is where raw <img> tags leak in. Transform the CMS document rather than trusting its HTML.
// lib/render-rich-text.jsx
import { CmsImage } from '@/components/CmsImage';
export const nodeRenderers = {
'embedded-asset': (node) => <CmsImage asset={node.data.target.fields} />,
'embedded-entry': (node) => <ReservedEmbed entry={node.data.target} />,
};
SEO impact: Editors keep full freedom to place media, and the front end keeps a single enforcement point — the combination that makes the fix durable rather than a one-off cleanup.
Validation: curl -s https://example.com/blog/my-post | grep -c '<img' and compare against the count of <img tags carrying both width and height. The two numbers must match.
Step 5: Give dimensionless embeds a declared floor
Third-party embeds have no intrinsic size and often resize themselves after load. A minimum height absorbs the arrival.
// components/ReservedEmbed.jsx — a known floor per embed type
const MIN_HEIGHT = { video: 360, map: 400, form: 520, social: 280 };
export function ReservedEmbed({ entry }) {
const min = MIN_HEIGHT[entry.kind] ?? 320;
return (
<div className="embed-slot" style={floor(min)} data-kind={entry.kind}>
<iframe src={entry.src} title={entry.title} loading="lazy" />
</div>
);
}
const floor = (px) => ({ minHeight: px + 'px', width: '100%' });
Validation: Throttle to Slow 3G and confirm nothing below the embed moves as it loads.
Step 6: Assert the budget in CI
{
"ci": {
"collect": { "url": ["https://staging.example.com/blog/reference-post"], "numberOfRuns": 3 },
"assert": {
"assertions": {
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }],
"unsized-images": "error"
}
}
}
}
Validation: Add an image without dimensions on a branch and confirm the unsized-images audit fails the job. The setup is described in integrating Lighthouse CI into headless pipelines.
SEO Impact Summary
Layout shift is scored across the whole session, so an unsized image far below the fold contributes exactly as much as one at the top.
| Signal | What improves | What breaks if misconfigured |
|---|---|---|
| Page experience | Editorial templates enter the good CLS bucket, which is often the last failing metric | A wrong aspect ratio reserves the wrong box and letterboxes every image |
| Reading experience | Text stops moving under the reader’s eye mid-sentence | An over-generous minimum height leaves visible empty gaps |
| Ad and embed revenue | Reserved slots stop mis-clicks caused by late-arriving content | Reserving space for an embed that fails to load leaves a permanent hole |
| Crawl parity | Server HTML matches the final layout, so rendered and source views agree | Client-side dimension patching keeps the shift and hides it from lab tools |
Measurable signals to watch:
- Field p75 CLS for the editorial URL group, tracked apart from the site aggregate.
- The count of
<img>elements in built HTML lackingwidth/height, which should be zero. - The
largestShiftTargetdistribution, which should stop naming media selectors entirely.
Edge Cases and Gotchas
Responsive art direction changes the ratio
If a <picture> element serves a 16:9 crop on desktop and a 4:5 crop on mobile, one reserved ratio is wrong at one breakpoint. Reserve per source with a media-query-driven aspect ratio rather than a single value on the wrapper.
Consent banners are layout shift with a legal deadline A cookie banner that pushes content down scores exactly like an unsized image. Render it as a fixed overlay rather than in flow, so it never participates in layout.
Blur-up placeholders can hide the problem without fixing it A blurred placeholder occupies the right box only if the placeholder itself is sized from the same metadata. If it is a fixed-height div, you have replaced one wrong height with another.
Late-arriving personalisation blocks A recommendation strip injected after hydration shifts everything below it. Reserve its height from the maximum card height, or move it below the fold where a shift is not counted against the metric.
Frequently Asked Questions
Why does CLS appear only on editorial pages?
Because editorial pages render rich text the editor controls, and rich-text renderers usually emit bare img and iframe elements with no dimensions. Template pages pass through components that already enforce sizing, so the problem concentrates wherever an editor can insert arbitrary content.
Is aspect-ratio enough on its own? For images, yes, provided the ratio is correct and the element has a width constraint — the browser then computes the height before any bytes arrive. For iframes and third-party embeds it is not, because they can resize themselves after load; those also need a declared minimum height.
Do late-loading fonts count as CMS-driven shift?
They are a different cause with a different fix, though they often appear in the same measurement. A font swap moves text blocks rather than media, and the remedy is a metric-matched fallback with size-adjust rather than reserved space.
Should I reserve space for images below the fold? Yes. Shifts are counted for the whole session, not just the initial viewport, so an unsized image four screens down still contributes the moment the reader scrolls to it. The same component handles both cases, so there is no reason to treat them differently.
Part of: Core Web Vitals in Headless Front Ends
Related
- Fixing LCP on Headless Product Pages — the paint-speed half of the same media pipeline
- Reducing INP on Hydration-Heavy Routes — the third metric, and the one most affected by how much of the page is interactive
- Composable CMS Architecture Basics — why asset metadata belongs in the content model rather than the front end