Fix CLS Issues on Craftcms (Layout Shift) | OpsBlu Docs

Fix CLS Issues on Craftcms (Layout Shift)

Stabilize Craft CMS layouts by sizing Asset transforms in Twig, reserving Matrix block containers, and preloading custom fonts.

Learn how to prevent and fix Cumulative Layout Shift (CLS) in Craft CMS by properly sizing images, handling dynamic content, optimizing Matrix blocks, and implementing best practices.

Understanding CLS in Craft CMS

CLS measures visual stability by tracking unexpected layout shifts during page load. Common causes in Craft CMS:

  • Images without dimensions from asset fields
  • Dynamic Matrix blocks loading asynchronously
  • Web fonts causing text reflow
  • Ads and embeds in content
  • Lazy-loaded content without placeholders

Target: CLS score should be less than 0.1 for good user experience.

Diagnosing CLS Issues

Step 1: Identify Layout Shifts

Use Chrome DevTools Performance tab:

// Run in browser console to track layout shifts
let cls = 0;
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      cls += entry.value;
      console.log('Layout shift detected:', entry);
      console.log('Current CLS:', cls);
      console.log('Shifted elements:', entry.sources);
    }
  }
}).observe({type: 'layout-shift', buffered: true});

Step 2: Use Lighthouse

lighthouse https://yoursite.com --only-categories=performance --view

Image Dimension Issues

Problem: Images Without Width/Height

{# BAD - No dimensions, causes layout shift #}
{% set image = entry.featuredImage.one() %}
<img src="{{ image.url }}" alt="{{ image.title }}">

Solution: Always Specify Dimensions

{# GOOD - Explicit dimensions prevent layout shift #}
{% set image = entry.featuredImage.one() %}
<img src="{{ image.getUrl({ width: 800 }) }}"
     alt="{{ image.title }}"
     width="800"
     height="{{ (image.height / image.width * 800)|round }}"
     loading="lazy">

Dynamic Aspect Ratio Calculation

{# Calculate aspect ratio to preserve layout #}
{% set image = entry.featuredImage.one() %}
{% set targetWidth = 800 %}
{% set aspectRatio = image.height / image.width %}
{% set targetHeight = (targetWidth * aspectRatio)|round %}

<img src="{{ image.getUrl({ width: targetWidth }) }}"
     alt="{{ image.title }}"
     width="{{ targetWidth }}"
     height="{{ targetHeight }}"
     loading="lazy">

Using aspect-ratio CSS Property

{% set image = entry.featuredImage.one() %}

<div class="image-container" style="aspect-ratio: {{ image.width }} / {{ image.height }};">
  <img src="{{ image.getUrl({ width: 800 }) }}"
       alt="{{ image.title }}"
       loading="lazy">
</div>

<style>
  .image-container {
    width: 100%;
    overflow: hidden;
  }
  .image-container img {
    width: 100%;
    height: auto;
    display: block;
  }
</style>

Responsive Images with Dimensions

Proper Responsive Image Implementation

{% set image = entry.featuredImage.one() %}
{% set aspectRatio = image.height / image.width %}

{# Define responsive breakpoints #}
{% set sizes = {
  mobile: 640,
  tablet: 1024,
  desktop: 1920
} %}

<picture>
  <source media="(min-width: 1024px)"
          srcset="{{ image.getUrl({ width: sizes.desktop }) }}"
          width="{{ sizes.desktop }}"
          height="{{ (sizes.desktop * aspectRatio)|round }}">

  <source media="(min-width: 768px)"
          srcset="{{ image.getUrl({ width: sizes.tablet }) }}"
          width="{{ sizes.tablet }}"
          height="{{ (sizes.tablet * aspectRatio)|round }}">

  <img src="{{ image.getUrl({ width: sizes.mobile }) }}"
       alt="{{ image.title }}"
       width="{{ sizes.mobile }}"
       height="{{ (sizes.mobile * aspectRatio)|round }}"
       loading="lazy">
</picture>

Art Direction with Maintained Aspect Ratios

{% set heroImage = entry.heroImage.one() %}

{# Different crops for different devices #}
{% set transforms = {
  mobile: { width: 640, height: 960, mode: 'crop', position: 'center-center' },
  tablet: { width: 1024, height: 768, mode: 'crop', position: 'center-center' },
  desktop: { width: 1920, height: 1080, mode: 'crop', position: 'center-center' }
} %}

<picture>
  <source media="(min-width: 1024px)"
          srcset="{{ heroImage.getUrl(transforms.desktop) }}"
          width="{{ transforms.desktop.width }}"
          height="{{ transforms.desktop.height }}">

  <source media="(min-width: 768px)"
          srcset="{{ heroImage.getUrl(transforms.tablet) }}"
          width="{{ transforms.tablet.width }}"
          height="{{ transforms.tablet.height }}">

  <img src="{{ heroImage.getUrl(transforms.mobile) }}"
       alt="{{ heroImage.title }}"
       width="{{ transforms.mobile.width }}"
       height="{{ transforms.mobile.height }}">
</picture>

Matrix Field Layout Shifts

Problem: Dynamic Matrix Blocks

Matrix blocks loading without reserved space:

{# BAD - No height reservation #}
{% for block in entry.contentBlocks.all() %}
  {% switch block.type.handle %}
    {% case 'imageBlock' %}
      {% set image = block.image.one() %}
      <img src="{{ image.url }}" alt="{{ image.title }}">
  {% endswitch %}
{% endfor %}

Solution: Reserve Space for Matrix Blocks

{# GOOD - Proper dimensions for all block types #}
{% for block in entry.contentBlocks.all() %}
  {% switch block.type.handle %}

    {% case 'imageBlock' %}
      {% set image = block.image.one() %}
      <div class="matrix-image-block">
        <img src="{{ image.getUrl({ width: 1200 }) }}"
             alt="{{ image.title }}"
             width="1200"
             height="{{ (image.height / image.width * 1200)|round }}"
             loading="lazy">
      </div>

    {% case 'textBlock' %}
      {# Reserve minimum height for text blocks #}
      <div class="matrix-text-block" style="min-height: 200px;">
        {{ block.text }}
      </div>

    {% case 'videoBlock' %}
      {# 16:9 aspect ratio container #}
      <div class="matrix-video-block" style="aspect-ratio: 16 / 9;">
        {{ block.videoEmbed }}
      </div>

    {% case 'galleryBlock' %}
      <div class="matrix-gallery-block">
        {% for image in block.images.all() %}
          <img src="{{ image.getUrl({ width: 400 }) }}"
               alt="{{ image.title }}"
               width="400"
               height="{{ (image.height / image.width * 400)|round }}"
               loading="lazy">
        {% endfor %}
      </div>

  {% endswitch %}
{% endfor %}

Skeleton Placeholders for Matrix Blocks

{# Use skeleton loaders while content loads #}
<style>
  .skeleton {
    background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
    background-size: 200% 100%;
    animation: loading 1.5s infinite;
  }

  @keyframes loading {
    0% { background-position: 200% 0; }
    100% { background-position: -200% 0; }
  }
</style>

{% for block in entry.contentBlocks.all() %}
  {% switch block.type.handle %}
    {% case 'imageBlock' %}
      {% set image = block.image.one() %}
      <div class="image-wrapper" style="aspect-ratio: {{ image.width }} / {{ image.height }};">
        <div class="skeleton" style="width: 100%; height: 100%;"></div>
        <img src="{{ image.getUrl({ width: 800 }) }}"
             alt="{{ image.title }}"
             width="800"
             height="{{ (image.height / image.width * 800)|round }}"
             loading="lazy"
      </div>
  {% endswitch %}
{% endfor %}

Font Loading Optimization

Problem: FOUT/FOIT Causing Layout Shift

Fonts loading late can cause text to reflow:

/* BAD - Can cause FOIT (Flash of Invisible Text) */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
}

Solution: font-display Strategy

<head>
  <style>
    @font-face {
      font-family: 'Inter';
      src: url('/fonts/inter-var.woff2') format('woff2');
      font-weight: 100 900;
      font-display: swap; /* Prevents invisible text */
      font-stretch: 25% 151%;
    }

    /* Match fallback font metrics */
    body {
      font-family: 'Inter', system-ui, -apple-system, sans-serif;
    }
  </style>

  {# Preload critical fonts #}
  <link rel="preload"
        href="/fonts/inter-var.woff2"
        as="font"
        type="font/woff2"
        crossorigin>
</head>

Using Fallback Font Metrics

/* Match fallback font to custom font metrics */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
  size-adjust: 107%;
}

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

Font Loading API

<script>
  // Load fonts asynchronously without layout shift
  if ('fonts' in document) {
    Promise.all([
      document.fonts.load('400 1em Inter'),
      document.fonts.load('700 1em Inter')
    ]).then(function() {
      document.documentElement.classList.add('fonts-loaded');
    });
  }
</script>

<style>
  /* Before fonts load */
  body {
    font-family: system-ui, -apple-system, sans-serif;
  }

  /* After fonts load */
  .fonts-loaded body {
    font-family: 'Inter', system-ui, -apple-system, sans-serif;
  }
</style>

Dynamic Content Handling

Entries with Variable Height Content

{# Reserve space for dynamic content #}
{% for entry in craft.entries().section('blog').limit(10).all() %}
  <article class="blog-card">
    {% set featuredImage = entry.featuredImage.one() %}

    {# Image with dimensions #}
    {% if featuredImage %}
      <img src="{{ featuredImage.getUrl({ width: 400 }) }}"
           alt="{{ featuredImage.title }}"
           width="400"
           height="{{ (featuredImage.height / featuredImage.width * 400)|round }}">
    {% else %}
      {# Placeholder with same dimensions #}
      <div class="placeholder" style="width: 400px; height: 300px; background: #f0f0f0;"></div>
    {% endif %}

    {# Text content with min-height #}
    <div class="content" style="min-height: 150px;">
      <h2>{{ entry.title }}</h2>
      <p>{{ entry.summary|truncate(120) }}</p>
    </div>
  </article>
{% endfor %}

Lazy-Loaded Content Sections

{# Reserve space for lazy-loaded sections #}
<div id="related-products" style="min-height: 400px;">
  <!-- Content loaded via AJAX -->
</div>

<script>
  // Load content without layout shift
  fetch('/api/related-products')
    .then(response => response.text())
    .then(html => {
      document.getElementById('related-products').innerHTML = html;
      // Remove min-height after content loads
      document.getElementById('related-products').style.minHeight = 'auto';
    });
</script>

Ads and Third-Party Embeds

Reserve Space for Ad Units

{# Fixed-size ad container #}
<div class="ad-container" style="width: 300px; height: 250px; background: #f5f5f5;">
  <!-- Ad code -->
  {{ entry.adCode|raw }}
</div>

Responsive Ad Containers

{# Responsive ad with aspect ratio #}
<div class="ad-wrapper" style="aspect-ratio: 16 / 9; max-width: 728px;">
  {{ entry.responsiveAd|raw }}
</div>

YouTube/Video Embeds

{# Embed with reserved aspect ratio #}
{% set videoUrl = block.videoUrl %}

<div class="video-embed" style="position: relative; padding-bottom: 56.25%; height: 0;">
  <iframe src="{{ videoUrl }}"
          style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
          frameborder="0"
          allowfullscreen></iframe>
</div>

Entry List/Grid Layouts

Consistent Card Heights

{% set entries = craft.entries().section('blog').limit(12).all() %}

<div class="entry-grid">
  {% for entry in entries %}
    <article class="entry-card">
      {# Fixed aspect ratio for images #}
      {% set image = entry.featuredImage.one() %}
      <div class="card-image" style="aspect-ratio: 16 / 9;">
        {% if image %}
          <img src="{{ image.getUrl({ width: 400, height: 225, mode: 'crop' }) }}"
               alt="{{ image.title }}"
               width="400"
               height="225"
               loading="lazy">
        {% endif %}
      </div>

      {# Fixed height for content #}
      <div class="card-content" style="min-height: 200px;">
        <h3>{{ entry.title }}</h3>
        <p>{{ entry.summary|truncate(120) }}</p>
      </div>
    </article>
  {% endfor %}
</div>

<style>
  .entry-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 2rem;
  }

  .entry-card {
    display: flex;
    flex-direction: column;
  }

  .card-image {
    width: 100%;
    overflow: hidden;
  }

  .card-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
</style>

Commerce Product Grids

Consistent Product Card Layout

{% set products = craft.products().limit(12).all() %}

<div class="product-grid">
  {% for product in products %}
    {% set variant = product.defaultVariant %}
    {% set image = product.featuredImage.one() %}

    <div class="product-card">
      {# Fixed aspect ratio product image #}
      <div class="product-image" style="aspect-ratio: 1 / 1;">
        {% if image %}
          <img src="{{ image.getUrl({ width: 400, height: 400, mode: 'crop' }) }}"
               alt="{{ product.title }}"
               width="400"
               height="400"
               loading="lazy">
        {% endif %}
      </div>

      {# Fixed height content area #}
      <div class="product-info" style="min-height: 100px;">
        <h3>{{ product.title }}</h3>
        <p class="price">{{ variant.price|currency(cart.currency) }}</p>
      </div>
    </div>
  {% endfor %}
</div>

<style>
  .product-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
    gap: 2rem;
  }

  .product-image {
    width: 100%;
    overflow: hidden;
    background: #f5f5f5;
  }

  .product-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
</style>

Monitoring CLS

Real User Monitoring

<script>
  // Track CLS for analytics
  let cls = 0;

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

  // Send to analytics on page unload
  window.addEventListener('pagehide', function() {
    gtag('event', 'web_vitals', {
      event_category: 'Web Vitals',
      event_label: 'CLS',
      value: Math.round(cls * 1000),
      metric_id: 'cls',
      metric_value: cls,
      non_interaction: true
    });
  });
</script>

Development Debugging

{% if craft.app.config.general.devMode %}
<script>
  // Visual debugging for layout shifts
  let cls = 0;

  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (!entry.hadRecentInput) {
        cls += entry.value;

        console.group('Layout Shift Detected');
        console.log('Shift Value:', entry.value);
        console.log('Current CLS:', cls);
        console.log('Affected Elements:', entry.sources);
        console.groupEnd();

        // Highlight shifted elements
        entry.sources?.forEach(source => {
          if (source.node) {
            source.node.style.outline = '3px solid red';
            setTimeout(() => {
              source.node.style.outline = '';
            }, 2000);
          }
        });
      }
    }
  }).observe({type: 'layout-shift', buffered: true});
</script>
{% endif %}

Quick Wins Checklist

  • Add explicit width/height to all images
  • Use aspect-ratio CSS for responsive images
  • Reserve space for Matrix blocks
  • Implement font-display: swap for web fonts
  • Preload critical fonts
  • Add min-height to dynamic content areas
  • Use fixed aspect ratios for video embeds
  • Reserve space for ads and third-party content
  • Ensure consistent card heights in grids
  • Use skeleton loaders for async content
  • Match fallback font metrics
  • Avoid injecting content above existing content

Next Steps

Resources