Fix Netlify CMS (Decap CMS) CLS Issues | OpsBlu Docs

Fix Netlify CMS (Decap CMS) CLS Issues

Stabilize Netlify CMS static sites by sizing Hugo/Jekyll image shortcodes, preloading build-time fonts, and constraining widget output.

Cumulative Layout Shift (CLS) measures visual stability during page load. Static sites should have minimal CLS since content is pre-rendered, but images without dimensions, web fonts, and dynamic ads can cause layout shifts.

What is CLS?

CLS measures unexpected layout shifts during page load.

Thresholds:

  • Good: < 0.1
  • Needs Improvement: 0.1 - 0.25
  • Poor: > 0.25

Common CLS Causes on Static Sites:

  • Images without dimensions
  • Web fonts loading (FOIT/FOUT)
  • Ads, embeds, iframes
  • Dynamically injected content
  • Animations that cause reflow

Measuring CLS

PageSpeed Insights

  1. Go to PageSpeed Insights
  2. Enter your site URL
  3. View Cumulative Layout Shift metric
  4. Check Diagnostics for specific elements causing shifts

Lighthouse CLI

lighthouse https://yoursite.com --view

# Focus on layout shift elements
lighthouse https://yoursite.com --only-categories=performance --view

Web Vitals Extension

Install Web Vitals Chrome Extension to see CLS in real-time.

Common CLS Issues on Static Sites

1. Images Without Dimensions

Problem: Images load after layout, causing content shift.

Hugo Solution:

<!-- Bad - no dimensions -->
<img src="{{ .Src }}" alt="{{ .Alt }}">

<!-- Good - explicit dimensions -->
{{ $image := resources.Get .Src }}
<img
  src="{{ $image.RelPermalink }}"
  width="{{ $image.Width }}"
  height="{{ $image.Height }}"
  alt="{{ .Alt }}">

<!-- Better - responsive with aspect ratio -->
{{ $image := resources.Get .Src }}
<img
  src="{{ $image.RelPermalink }}"
  width="{{ $image.Width }}"
  height="{{ $image.Height }}"
  style="aspect-ratio: {{ $image.Width }} / {{ $image.Height }}; width: 100%; height: auto;"
  alt="{{ .Alt }}">

Jekyll Solution:

<!-- Use imagemagick or sharp to get dimensions at build time -->
{% assign image = site.static_files | where: "path", include.src | first %}

<img
  src="{{ include.src }}"
  width="{{ image.width }}"
  height="{{ image.height }}"
  alt="{{ include.alt }}">

Gatsby Solution:

import { GatsbyImage } from 'gatsby-plugin-image';

// Automatic aspect ratio preservation
<GatsbyImage
  image={data.heroImage.childImageSharp.gatsbyImageData}
  alt="Hero"
/>

Next.js Solution:

import Image from 'next/image';

// Automatic aspect ratio with width/height
<Image
  src="/hero.jpg"
  width={1920}
  height={1080}
  alt="Hero"
/>

// Or use fill with container
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9' }}>
  <Image
    src="/hero.jpg"
    fill
    style={{ objectFit: 'cover' }}
    alt="Hero"
  />
</div>

2. Web Font Loading

Problem: Fonts load and cause text to reflow (FOIT/FOUT).

Solution: font-display: swap

@font-face {
  font-family: 'YourFont';
  src: url('/fonts/your-font.woff2') format('woff2');
  font-display: swap; /* Show fallback immediately */
}

Solution: Preload fonts

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

Solution: Match fallback font metrics

/* Size fallback font to match web font */
body {
  font-family: 'YourWebFont', Arial, sans-serif;
  font-size: 16px;
  line-height: 1.5;
}

/* Adjust fallback to match web font metrics */
@font-face {
  font-family: 'YourWebFont Fallback';
  src: local('Arial');
  ascent-override: 105%;
  descent-override: 35%;
  line-gap-override: 10%;
  size-adjust: 95%;
}

Solution: Use system fonts (no CLS)

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}

3. Ads and Embeds

Problem: Ads/embeds without reserved space cause layout shifts.

Solution: Reserve space for ads

<!-- Reserve space before ad loads -->
<div style="min-height: 250px;">
  <div id="ad-container"></div>
</div>

Solution: Use aspect ratio for embeds

<!-- YouTube embed with aspect ratio -->
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
  <iframe
    src="https://www.youtube.com/embed/VIDEO_ID"
    style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
    frameborder="0"
    allowfullscreen>
  </iframe>
</div>

Hugo shortcode for embeds:

<!-- layouts/shortcodes/youtube.html -->
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
  <iframe
    src="https://www.youtube.com/embed/{{ .Get 0 }}"
    style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
    frameborder="0"
    allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
    allowfullscreen>
  </iframe>
</div>

Usage:

{{< youtube "dQw4w9WgXcQ" >}}

4. Dynamic Content Injection

Problem: Analytics scripts, chat widgets inject content that shifts layout.

Solution: Load below-the-fold

<!-- Place dynamic scripts at end of body -->
<body>
  <!-- Static content -->

  <!-- Dynamic scripts at end -->
  <script src="analytics.js"></script>
  <script src="chat-widget.js"></script>
</body>

Solution: Reserve space for dynamic elements

<!-- Chat widget placeholder -->
<div id="chat-widget" style="position: fixed; bottom: 20px; right: 20px; width: 60px; height: 60px;"></div>

Solution: Delay loading

// Load chat widget after page is stable
window.addEventListener('load', function() {
  setTimeout(function() {
    loadChatWidget();
  }, 2000);
});

5. CSS Animations and Transitions

Problem: Animations that affect layout cause CLS.

Bad (causes CLS):

/* Animating width/height causes layout shift */
.element {
  transition: width 0.3s;
}
.element:hover {
  width: 200px;
}

Good (no CLS):

/* Animating transform doesn't cause layout shift */
.element {
  transition: transform 0.3s;
}
.element:hover {
  transform: scaleX(1.2);
}

Use transform instead of:

  • width, heighttransform: scale()
  • top, lefttransform: translate()
  • margin, padding → Use fixed dimensions

6. Loading Indicators

Problem: Spinners/loaders that appear then disappear shift content.

Solution: Absolute positioning

<div style="position: relative; min-height: 200px;">
  <!-- Content loads here -->
  <div id="content"></div>

  <!-- Loader doesn't affect layout -->
  <div id="loader" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
    <div class="spinner"></div>
  </div>
</div>

Framework-Specific Fixes

Hugo CLS Prevention

Image dimensions from resources:

{{ $image := resources.Get "images/hero.jpg" }}
{{ $resized := $image.Resize "1920x jpg q85" }}

<img
  src="{{ $resized.RelPermalink }}"
  width="{{ $resized.Width }}"
  height="{{ $resized.Height }}"
  alt="Hero">

Responsive images with aspect ratio:

{{ $image := resources.Get .Src }}
{{ $small := $image.Resize "640x webp q85" }}
{{ $medium := $image.Resize "1024x webp q85" }}
{{ $large := $image.Resize "1920x webp q85" }}

<img
  srcset="{{ $small.RelPermalink }} 640w,
          {{ $medium.RelPermalink }} 1024w,
          {{ $large.RelPermalink }} 1920w"
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 1024px, 1920px"
  src="{{ $large.RelPermalink }}"
  width="{{ $large.Width }}"
  height="{{ $large.Height }}"
  style="width: 100%; height: auto;"
  alt="{{ .Alt }}">

Gatsby CLS Prevention

Use gatsby-plugin-image:

import { GatsbyImage, getImage } from 'gatsby-plugin-image';

const BlogPost = ({ data }) => {
  const image = getImage(data.markdownRemark.frontmatter.hero);

  return (
    <article>
      {/* Automatic aspect ratio, no CLS */}
      <GatsbyImage image={image} alt="Hero" />
      <div dangerouslySetInnerHTML={{ __html: data.markdownRemark.html }} />
    </article>
  );
};

export const query = graphql`
  query($slug: String!) {
    markdownRemark(fields: { slug: { eq: $slug } }) {
      html
      frontmatter {
        hero {
          childImageSharp {
            gatsbyImageData(width: 1920, placeholder: BLURRED)
          }
        }
      }
    }
  }
`;

Next.js CLS Prevention

Use next/image:

import Image from 'next/image';

// Layout="responsive" prevents CLS
<Image
  src="/hero.jpg"
  width={1920}
  height={1080}
  layout="responsive"
  alt="Hero"
/>

// Or use fill with container
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9' }}>
  <Image
    src="/hero.jpg"
    fill
    sizes="100vw"
    alt="Hero"
  />
</div>

Reserve space for dynamic content:

const [content, setContent] = useState(null);

return (
  <div style={{ minHeight: '500px' }}>
    {content ? (
      <div>{content}</div>
    ) : (
      <div style={{ height: '500px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
        <Spinner />
      </div>
    )}
  </div>
);

Tracking Script CLS Prevention

Analytics Scripts

Load in footer:

<!-- Hugo -->
{{ define "main" }}
  {{ .Content }}
{{ end }}

{{ define "footer" }}
  {{ partial "analytics.html" . }}
{{ end }}

Async loading:

<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>

GTM Container

Defer GTM loading:

// Load GTM after page is stable
window.addEventListener('load', function() {
  (function(w,d,s,l,i){
    w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});
    var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';
    j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
    f.parentNode.insertBefore(j,f);
  })(window,document,'script','dataLayer','GTM-XXXXXX');
});

Testing for CLS

Visual Regression Testing

# Install BackstopJS
npm install -g backstopjs

# Initialize
backstop init

# Configure backstop.json
{
  "scenarios": [
    {
      "label": "Homepage",
      "url": "https://yoursite.com",
      "delay": 1000
    }
  ]
}

# Create reference
backstop reference

# Test for changes
backstop test

Monitor Real Users

import {getCLS} from 'web-vitals';

getCLS(({name, value, id}) => {
  // Send to GA4
  gtag('event', name, {
    event_category: 'Web Vitals',
    value: Math.round(value * 1000),
    event_label: id,
    non_interaction: true,
  });
});

Checklist for CLS Prevention

  • All images have width/height - Prevents layout shift
  • Use aspect-ratio CSS - Maintain layout during load
  • Preload web fonts - Reduce FOUT/FOIT
  • font-display: swap - Show fallback text immediately
  • Reserve space for ads/embeds - Use min-height or aspect ratio
  • Load analytics in footer - Don't inject above-the-fold
  • Absolute position for loaders - Don't affect layout
  • Use transform for animations - Not width/height/margin
  • Static site = pre-rendered layout - Leverage build-time rendering
  • Test with slow 3G - Exaggerate loading delays

Next Steps