Home / Blog / CLS culprits: from font loading to ad slots, and how to fix each one

CLS culprits: from font loading to ad slots, and how to fix each one

Cumulative Layout Shift (CLS) is Google's visual stability metric. Where layout shifts come from, and how to fix them.

CLS (Cumulative Layout Shift) is that “why did the content just jump?” feeling users get while reading a page. The button moves down as they click. The ad loads while they’re reading an article and pushes content down. Frustrating, trust-eroding.

Google measures it as part of Core Web Vitals. Below 0.1 is “good”, above 0.25 is “poor”. SEO impact is real.

This post covers the seven main sources of CLS and how to fix each one.

What is CLS and how is it calculated?

Formula: Impact fraction times Distance fraction equals Layout shift score

  • Impact fraction: what percentage of the viewport shifted?
  • Distance fraction: how far the element moved as a percentage of viewport height?

Cumulative means the sum of every shift across the page load.

Example:

  • Element covers 50% of the viewport, shifts 25% down
  • Layout shift: 0.5 * 0.25 = 0.125

0.125 lands in “needs improvement”.

Source 1: Images without dimensions

The most common cause. Image loads, space gets allocated, content moves down.

Bad:

<img src="hero.jpg" alt="...">

Until the image loads, the browser treats it as 0 height. When it loads, it expands and shifts.

Good:

<img src="hero.jpg" width="800" height="400" alt="...">

The browser sees the dimensions and pre-allocates the space. Even as the image loads, the space doesn’t change.

CSS responsive:

img {
    width: 100%;
    height: auto;
    aspect-ratio: 2 / 1;
}

aspect-ratio is widely supported in modern browsers and holds the dimensions.

Source 2: Web fonts (FOUT)

Web font loads, the fallback font swaps to the custom font. Font metrics differ, text reflows.

Problem:

  • Fallback font is narrower, text takes 3 lines
  • Custom font is wider, text takes 4 lines
  • On swap, everything below shifts

Solutions:

1. font-display: optional

@font-face{ 
    font-family: 'Custom';
    src: url('custom.woff2');
    font-display:swap;
 }

If the custom font doesn’t arrive in 100ms, it’s never loaded. No swap, no shift. UX compromise (sometimes the custom font never shows).

2. size-adjust CSS (newer browsers):

@font-face{ font-display:swap;
    font-family: 'Custom';
    src: url('custom.woff2');
    size-adjust: 95%;
    ascent-override: 90%;
    descent-override: 20%;
    line-gap-override: 0%;
 }

Matches fallback font metrics to the custom font. Minimal shift on swap.

3. Preload critical fonts

<link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" crossorigin>

Critical font loads earlier, swap time is short.

Source 3: Ads and iframes

Ad units are an unknown size. When they load, they open space and push content down.

Solution: reserve the space.

.ad-slot {
    min-height: 250px;  /* Expected ad height */
    min-width: 300px;
}

HTML:

<div class="ad-slot">
    <!-- Ad script injects here -->
</div>

Even if the ad doesn’t load, the space is reserved. No shift.

Multiple ad sizes: if the ad network can return multiple sizes, set min-height to the average. Smaller ads leave extra whitespace; larger ones are prevented from shifting.

Source 4: Dynamically injected content

User scrolls, banner injects, cookie consent popup appears, newsletter subscribe shows up. New content lands in the DOM and existing content shifts.

Rules:

  1. Never inject above existing content. New content always goes below or as an overlay.
  2. User-interaction triggered. When they click “Close”, a shift within a 500ms window is allowed and not counted toward CLS.
  3. Overlay/modal pattern. Content doesn’t change the existing layout, it sits on top.
.modal {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 1000;
}

Fixed positioning, doesn’t affect flow.

Source 5: CSS animations

Bad CSS animations animate layout-triggering properties:

.el {
    transition: top 0.3s, left 0.3s, width 0.3s, height 0.3s;
}

These property changes trigger layout recalc. Paint plus composite is expensive.

Good:

.el {
    transition: transform 0.3s, opacity 0.3s;
}

Transform and opacity are compositor-only. GPU-accelerated. No layout reflow.

No contribution to CLS (visually in the same coordinates), and general performance improves.

Source 6: Embeds (video, tweets, etc.)

YouTube embed, Twitter embed, Instagram embed. Every one of them is async loaded with unknown initial size.

Solution: explicit dimensions or aspect ratio:

<div style="aspect-ratio: 16/9; background: #eee;">
    <iframe src="https://youtube.com/embed/..." width="100%" height="100%"></iframe>
</div>

Video is 16:9, container is pre-allocated with aspect-ratio.

Twitter/Instagram widgets: use a reasonable min-height.

Source 7: Skeleton loaders done wrong

Skeleton loaders are trendy. But if they’re not implemented correctly, they create CLS.

Bad skeleton:

<div class="loading">
    <div class="spinner"></div>
</div>
<!-- User data loads, replaced with -->
<div class="user-profile">
    <h1>...</h1>
    <img ...>
    <p>...</p>
</div>

Spinner size is not profile size. Huge shift.

Good skeleton:

<div class="skeleton">
    <div class="skeleton-title"></div>
    <div class="skeleton-avatar"></div>
    <div class="skeleton-text"></div>
    <div class="skeleton-text"></div>
</div>
.skeleton-title { height: 30px; width: 80%; background: #eee; }
.skeleton-avatar { width: 60px; height: 60px; border-radius: 50%; background: #eee; }
.skeleton-text { height: 20px; width: 100%; background: #eee; }

The skeleton looks like the real layout. Minimal shift when data loads.

Measuring CLS

Lab tools:

  • Lighthouse (single page)
  • PageSpeed Insights
  • WebPageTest

Field tools:

  • Chrome DevTools Performance Insights
  • Google Search Console Core Web Vitals
  • web-vitals library (custom analytics)
import {onCLS} from 'web-vitals';

onCLS((metric) => {
    console.log('CLS:', metric.value);
    // Send to analytics
    gtag('event', 'web_vital', {
        metric_name: 'CLS',
        value: Math.round(metric.value * 1000)
    });
});

Field data reflects real user experience in production.

Debugging process

Debugging a CLS issue:

  1. Chrome DevTools Performance tab. Start a recording. Layout shift events show up in the “Experience” track.
  2. Visualize layout shift regions. DevTools Rendering > Layout Shift Regions. A shift flashes red.
  3. Identify the shifting element. DevTools shows the shift nodes. Which element is moving?
  4. Find root cause. Why is it shifting? Missing image dimensions? Font loaded? Ad injected?
  5. Apply the fix.
  6. Verify. Re-measure. Did CLS drop?

WordPress-specific

Common CLS sources in WordPress:

1. Plugin ads. Injections from plugins you don’t need.

2. Theme sliders. Slider JS loads late with no space reservation.

3. Lazy-loaded images. WP 5.5+ lazy loads by default. Turn it off for the hero image.

4. Font loading. Google Fonts direct links are external requests. Self-host and optimize.

5. Comment forms. Heavy comment forms below the fold can expand.

Optimization strategy is the same; the context is WP-specific.

CLS vs interactivity

Some CLS falls within “accepted” tolerance. User-initiated shifts are fine:

  • Accordion expand
  • Tab switch
  • Show more button

The browser doesn’t count these shifts toward CLS (500ms window after user interaction).

Passive shifts (page load, ad inject) count. Active shifts (user caused) don’t.

Takeaway

CLS is the metric for visual stability. Seven common sources: image dimensions, fonts, ads, dynamic injection, animations, embeds, bad skeletons.

Each source has its own fix: width/height attributes, font-display optional plus size-adjust, reserved space for ads, transform-based animations, aspect-ratio containers.

Measure with both lab and field tools. Monitor continuously in production.

CLS fixes are usually cheap (CSS changes). Big UX improvement, SEO benefit. Low-effort, high-value optimization.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch