Skip to content
Security

CLS prevention

The five causes of layout shift, the font metric override that fixes most of it, and how to attribute a shift to the element that caused it.

Written forFrontend engineers and designers working on content-heavy pages
Reading time9 min read
Last reviewed2026-09-04

Cumulative Layout Shift measures how much visible content moves without the user causing it. It is the vital most visible to a human — the tap that lands on the wrong button because an advert loaded — and the one most often "fixed" in the lab while remaining bad in the field, because the shifts happen at network speeds a lab run never sees.

Five causes, in the order we find them

01
Images without dimensionsThe browser cannot reserve space it has not been told about. Width and height attributes — or an aspect-ratio — let it allocate the box before the bytes arrive. Next.js Image handles this only if you give it real dimensions rather than fill inside an unsized parent.
02
Web fonts swappingThe fallback renders, then the web font arrives with different metrics and every line re-flows. This is the largest single contributor on most text-heavy sites, and it is the easiest to fix properly.
03
Content injected above the foldBanners, cookie notices, promotional strips and A/B test variants that mount after hydration push everything down. Reserve their space in the initial HTML or render them below the fold.
04
Async content in a fixed flowA list that renders empty, then fills; a card whose badge appears when a second request resolves. Skeletons work only if the skeleton is the same height as the content that replaces it — a common near-miss.
05
Third-party embedsMaps, video players and widgets that size themselves after load. Wrap them in a container with an explicit aspect ratio and let them fill it.

The font fix that actually works

font-display: swap removes invisible text but guarantees a shift when the real font lands. The fix is to make the fallback occupy the same space as the web font, using the metric-override descriptors — then the swap is a change of shape rather than a change of layout.

css
@font-face {
  font-family: 'Spectral Fallback';
  src: local('Georgia');
  size-adjust: 105.9%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}

body {
  font-family: 'Spectral', 'Spectral Fallback', Georgia, serif;
}
Overrides are computed from the real font's metrics. next/font does this automatically when you pass a fallback.

Attribute the shift to an element

A CLS score tells you there is a problem; attribution tells you which element caused it. Without that you are guessing, and the guess is usually wrong because the largest shift is rarely the one you noticed.

ts
import { onCLS } from 'web-vitals/attribution';

onCLS(({ value, attribution: a }) => {
  navigator.sendBeacon('/rum', JSON.stringify({
    metric: 'CLS',
    value,
    element: a.largestShiftTarget,     // the CSS selector that moved
    time: a.largestShiftTime,
    route: routePattern(),
  }));
});

Shifts that do not count, and one that should not happen

  • A shift within 500ms of a user interaction is excluded — expanding an accordion is not penalised. This is why moving content in response to a tap is fine and moving it on a timer is not.
  • Transform-based movement does not shift layout, so animate with transform rather than top or margin.
  • CLS is measured across the whole session, not just the load. A late-loading footer widget or an infinite scroll that jumps can ruin an otherwise clean page — check the metric on long sessions, not only on first paint.