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.

The same article with and without reserved media space Two page mock-ups side by side: without reserved space the paragraph blocks sit high and then jump down when the image arrives; with reserved space they render in their final position immediately. no dimensions — content jumps space reserved — stable image arrives late pushed down box sized from metadata The fix is knowing the ratio before the bytes arrive — nothing else changes

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.

Shifts count wherever they happen in the page A tall page outline with shift events marked at the top, middle, and bottom, all contributing to one session score. one page hero image shifts mid-article embed shifts related strip shifts one session score
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 lacking width/height, which should be zero.
  • The largestShiftTarget distribution, 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.

Shift sources and the technique that removes each Four labelled shift sources on the left — unsized image, third-party embed, consent banner, and personalisation strip — each connected to its remedy on the right. source of shift removal technique unsized CMS image third-party embed consent banner in flow personalisation strip aspect-ratio box from asset metadata declared minimum height per kind fixed overlay, never in flow reserve max card height, or move below fold

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