EmpireUI
Get Pro
← Blog9 min read#cls#layout shift#fonts

CLS Optimization Guide: Aspect Ratios, Font Fallbacks, Ad Slots

Stop your UI from jumping around. A practical guide to fixing Cumulative Layout Shift with aspect ratios, font fallbacks, and stable ad slots.

geometric layout grid with overlapping colored blocks on dark background

What CLS Actually Is (And Why 0.1 Isn't That Easy)

Cumulative Layout Shift measures how much your page's content moves around while it loads. Google's threshold is a score of 0.1 or below to hit "Good" — sounds trivial until you're staring at a 0.42 on PageSpeed and wondering what's happening. That number isn't one big shift; it's the sum of every unexpected layout jump during the page's life, weighted by the fraction of the viewport affected and how far elements travel.

In practice, a single 300px hero image without dimensions dropping in above a paragraph will spike your score well past 0.25 on mobile. And because Google uses field data from Chrome users (via CrUX), your lab scores and real-world scores can diverge significantly — especially if you have slow connections or large ad networks that load asynchronously.

The three biggest culprits are almost always: images without reserved space, web fonts swapping in after text renders with a fallback, and dynamically injected content (ads, banners, cookie notices) that shoves existing content down. This guide covers all three with actual fixes.

Images and Videos: Reserve Space With Aspect Ratio

The fix for images has been dead simple since browsers added native aspect-ratio support in 2021. Set width and height attributes on every <img> tag. That's it. Modern browsers (Chrome 79+, Firefox 71+, Safari 15) use those attributes to reserve the correct space before the image loads, so nothing shifts when the bytes arrive.

<!-- Bad: no dimensions, causes CLS -->
<img src="hero.webp" alt="Hero" />

<!-- Good: browser reserves 1200x630 space immediately -->
<img src="hero.webp" width="1200" height="630" alt="Hero" />

If you're using CSS width: 100% for responsive images (which you should be), the browser still uses the width/height ratio to calculate the reserved height. You don't need to hard-code pixel dimensions in CSS — just put them in the HTML attributes. For videos and iframes, the CSS aspect-ratio property is your friend:

.video-wrapper {
  aspect-ratio: 16 / 9;
  width: 100%;
  overflow: hidden;
}

.video-wrapper iframe {
  width: 100%;
  height: 100%;
}

Worth noting: Next.js's <Image> component handles this automatically when you pass width and height props. If you're building components using Empire UI or a custom design system, make sure your card and media components enforce dimension props — don't make them optional. A missing height prop on a card image will cost you on every page that uses it.

Font Fallbacks: Stop the Flash of Unstyled Text

Web fonts are a sneaky CLS source because the shift is subtle — it's not a 200px jump, it's the line heights and character widths changing as your custom font swaps in for the fallback. On slow connections, that swap can happen 2-3 seconds after first paint. If your fallback is Georgia and your brand font is Inter, the word spacing difference alone can reflow multiple lines.

The blunt fix is font-display: optional, which tells the browser to skip the web font entirely if it hasn't loaded by the time text needs to render. Zero CLS, but you're gambling on your brand font not appearing on slow connections. More balanced is font-display: swap combined with a well-tuned fallback stack using size-adjust, ascent-override, and descent-override:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
}

/* Fallback that matches Inter's metrics closely */
@font-face {
  font-family: 'Inter-Fallback';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter-Fallback', Arial, sans-serif;
}

Honestly, most teams skip the override properties and wonder why they still have CLS after switching to swap. The size-adjust value in particular is critical — you need to measure both fonts and calculate the ratio. Tools like fontaine or the next/font module in Next.js 13+ generate these overrides automatically, which is why you should be using them.

Quick aside: if you're self-hosting fonts (you should be — Google Fonts adds a round-trip), put the <link rel="preload"> tag for your woff2 file in <head> before any stylesheets. That 100-200ms head start often means the font arrives before first paint, making the whole swap question moot.

Ad Slots and Dynamic Content: Reserve Space Upfront

Ads are the hardest CLS problem because you often don't control the creative size until the auction resolves. The standard fix is to reserve a minimum height for every ad slot before the ad loads. Set a min-height that matches the most common format — 250px for a medium rectangle, 90px for a leaderboard — so the slot takes up space even when empty.

.ad-slot {
  min-height: 250px;
  width: 300px;
  /* Optional: show a placeholder background */
  background: #f0f0f0;
  display: flex;
  align-items: center;
  justify-content: center;
}

The bigger risk is content injected via JavaScript after the page loads — cookie banners, chat widgets, newsletter popups. If those mount *above* existing content, they push everything down. That's a layout shift. The fix: position them fixed/sticky so they overlay content instead of pushing it, or mount them in a reserved slot that already has height.

// Bad: mounts above content, causes shift
const CookieBanner = () => (
  <div style={{ position: 'relative' }}>
    Accept cookies?
  </div>
);

// Good: overlays, doesn't affect document flow
const CookieBanner = () => (
  <div style={{
    position: 'fixed',
    bottom: '16px',
    left: '50%',
    transform: 'translateX(-50%)',
    zIndex: 9999,
  }}>
    Accept cookies?
  </div>
);

Look, the same principle applies to skeleton loaders. A skeleton that renders at a different height than the real content it replaces is still causing CLS. Match your skeleton dimensions exactly — if your card is 320px tall, your skeleton wrapper needs to be 320px tall. Check out how Empire UI's component patterns handle skeleton states if you want a reference for getting the reserved dimensions right.

Animations and Transforms: The Safe Zone

Not every visual change causes CLS. Google specifically excludes layout shifts triggered by user interaction (within 500ms of a click, tap, or keypress) and excludes elements animated with CSS transform or opacity. This is why transforms are your best friend for UI motion.

If you're building UI animations — things like cards sliding in, modals appearing, toast notifications — use transform: translateY() instead of changing top, margin-top, or height. The browser handles transforms on the compositor thread; they don't trigger layout, so they don't contribute to CLS.

/* Causes CLS if it happens outside user interaction window */
.toast {
  transition: margin-top 0.3s ease;
  margin-top: -60px; /* initial */
}
.toast.visible {
  margin-top: 0;
}

/* Safe: transform doesn't trigger layout */
.toast {
  transform: translateY(-60px);
  transition: transform 0.3s ease;
}
.toast.visible {
  transform: translateY(0);
}

That said, if your animation triggers as part of page load (not user interaction), it can still add to CLS if it moves other elements. The rule: animate the element itself with transforms, never animate properties that affect surrounding layout. This pairs well with the motion patterns used in glassmorphism components and other animation-heavy UI styles — the blur and opacity transitions look great precisely because they don't cause shifts.

Measuring and Debugging CLS in the Browser

PageSpeed Insights gives you a score, but it doesn't tell you *what* shifted. For that, open Chrome DevTools, go to Performance, record a page load, and look for the "Experience" row — every purple Layout Shift block shows you exactly which elements moved and by how much. Click any block and you'll see the "Affected Node" in the summary panel.

For field data, the Web Vitals library gives you per-session CLS values with attributions that include the largest contributing shift's target element:

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

onCLS((metric) => {
  const entry = metric.attribution.largestShiftEntry;
  const target = metric.attribution.largestShiftTarget;
  console.log('CLS:', metric.value);
  console.log('Shifted element:', target);
  console.log('Shift entry:', entry);
});

One more thing — the PerformanceObserver API lets you log layout shifts in production without shipping the whole web-vitals bundle:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      console.log('Layout shift:', entry.value, entry.sources);
    }
  }
});
observer.observe({ type: 'layout-shift', buffered: true });

The entry.sources array is what you want — it contains the DOM nodes that moved. Log these to your analytics and you'll quickly discover which component is responsible for 80% of your score. In most codebases, fixing two or three components drops CLS below 0.1 permanently.

Putting It All Together: A CLS Checklist

After auditing dozens of sites, the pattern is always the same: a few known categories of issue account for virtually all CLS. Here's the full checklist you can run through before shipping any page:

Images: Every <img> has explicit width and height attributes. Videos and embeds use aspect-ratio CSS. The box shadow generator and other tool pages with dynamic previews should use fixed-height preview containers.

Fonts: Use font-display: swap with size-adjust overrides on your fallback. Preload critical woff2 files. If you're using Next.js, switch to next/font — it does all of this for you and the performance difference is noticeable.

Dynamic content: Ad slots get min-height. Cookie banners and chat widgets use position: fixed. Skeleton loaders match real content dimensions exactly. Any content injected post-load goes into pre-sized containers or overlays.

Animations: Only use transform and opacity for motion. Never animate height, margin, padding, or top/left on elements that affect document flow. Check the gradient generator page as an example of heavy interactive UI that still scores well on CLS — the preview updates use transform-based transitions.

That's the whole playbook. CLS isn't mysterious — it's a checklist problem. Work through these four categories methodically and you'll hit 0.1 on the first pass.

FAQ

Does lazy loading images cause CLS?

It can. Even with loading="lazy", if you don't set explicit width and height attributes, the browser won't know how much space to reserve. Always set both attributes regardless of whether you're lazy loading — the dimensions are what prevent the shift, not the loading strategy.

Why is my CLS score good in Lighthouse but bad in field data?

Lighthouse runs in a controlled lab environment with a fast simulated connection. Field data (CrUX) reflects real Chrome users, including slow connections where fonts and ads take longer to load. Dynamic content that loads quickly in the lab often causes large shifts on real mobile connections.

Does font-display: optional actually eliminate font-related CLS?

Yes — if the font isn't cached and doesn't arrive before the first render, the browser just uses the fallback and never swaps. No swap means no CLS. The tradeoff is that first-time visitors on slow connections may never see your brand font, which is why most teams prefer font-display: swap with well-tuned fallback metrics.

Can CSS animations cause CLS even with transforms?

Not if they're transform-only. CSS transforms (translate, scale, rotate) and opacity changes are compositor-only and excluded from CLS measurement. The problem comes from animating layout-affecting properties like height, padding, or margin, or from animations that trigger as part of initial page load and move surrounding content.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

LCP Optimization Guide: Images, Fonts, Server Response and CacheCore Web Vitals in 2026: LCP, CLS, INP — Measure and FixNext.js Font Optimization: next/font, Variable Fonts, Layout ShiftWeb Font Loading in 2026: next/font, variable fonts and CLS