Fixing CLS Issues on Drupal | Blue Frog Docs

Fixing CLS Issues on Drupal

Comprehensive guide to diagnosing and fixing Cumulative Layout Shift issues on Drupal sites

Fixing CLS Issues on Drupal

Overview

Cumulative Layout Shift (CLS) measures visual stability by tracking unexpected layout shifts during page load. Good CLS (under 0.1) ensures a smooth user experience. This guide covers Drupal-specific causes and solutions for layout shifts.


Understanding CLS in Drupal

What Causes Layout Shifts?

Common sources in Drupal:

  • Images without dimensions (from WYSIWYG)
  • BigPipe placeholder replacements
  • Dynamically injected content (ads, embeds)
  • Web fonts loading (FOUT/FOIT)
  • Toolbar and admin menu
  • Dynamic Paragraphs
  • AJAX-loaded Views

CLS Targets

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

Diagnosing CLS Issues

Identify Layout Shifts

Using Chrome DevTools:

  1. Open DevTools (F12)
  2. Go to Performance tab
  3. Check ✅ Experience in settings
  4. Click Record
  5. Reload page
  6. Look for red Layout Shift bars
  7. Click to see which elements shifted

Using Web Vitals Extension:

Install Web Vitals Chrome extension to see:

  • Real-time CLS score
  • Elements causing shifts
  • Shift visualization

Using Layout Shift GIF Generator:

https://defaced.dev/tools/layout-shift-gif-generator/

Enter URL to generate animated GIF showing shifts.


Solution 1: Fix Image Dimensions

Add Width/Height to Image Fields

For Image Styles:

Navigate to: /admin/config/media/image-styles

Ensure all image styles have explicit dimensions defined.

Update Image Field Templates

File: templates/field/field--field-image.html.twig

{#
/**
 * @file
 * Theme override for image fields with explicit dimensions.
 */
#}
{% for item in items %}
  <div{{ item.attributes.addClass('field__item') }}>
    {% set image_data = item.content['#item'].entity %}
    {% if image_data %}
      {% set width = item.content['#item'].width %}
      {% set height = item.content['#item'].height %}

      {# Add width/height attributes #}
      {{ item.content|merge({
        '#attributes': {
          'width': width,
          'height': height,
          'loading': 'lazy'
        }
      }) }}
    {% else %}
      {{ item.content }}
    {% endif %}
  </div>
{% endfor %}

Add Aspect Ratio Containers

File: templates/node/node--article--full.html.twig

{% if content.field_hero_image %}
  <div class="hero-image-container" style="aspect-ratio: 16/9;">
    {{ content.field_hero_image }}
  </div>
{% endif %}

CSS:

.hero-image-container {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9; /* Modern browsers */
}

.hero-image-container img {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Fallback for older browsers */
@supports not (aspect-ratio: 16 / 9) {
  .hero-image-container::before {
    content: '';
    display: block;
    padding-top: 56.25%; /* 16:9 ratio */
  }
}

Fix WYSIWYG Images

Install Image Resize Filter:

composer require drupal/image_resize_filter
drush en image_resize_filter -y

Configure: /admin/config/content/formats/manage/full_html

Add Image Resize Filter to text format:

  • ✅ Add width/height attributes to images
  • ✅ Generate responsive image styles

Or use custom filter:

File: modules/custom/image_dimensions/src/Plugin/Filter/ImageDimensionsFilter.php

<?php

namespace Drupal\image_dimensions\Plugin\Filter;

use Drupal\filter\FilterProcessResult;
use Drupal\filter\Plugin\FilterBase;

/**
 * @Filter(
 *   id = "image_dimensions",
 *   title = @Translation("Add image dimensions"),
 *   type = Drupal\filter\Plugin\FilterInterface::TYPE_TRANSFORM_REVERSIBLE
 * )
 */
class ImageDimensionsFilter extends FilterBase {

  public function process($text, $langcode) {
    $dom = new \DOMDocument();
    @$dom->loadHTML('<?xml encoding="UTF-8">' . $text, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);

    $images = $dom->getElementsByTagName('img');

    foreach ($images as $img) {
      $src = $img->getAttribute('src');

      // Skip if already has dimensions
      if ($img->hasAttribute('width') && $img->hasAttribute('height')) {
        continue;
      }

      // Get image dimensions
      if (strpos($src, '/files/') !== FALSE) {
        $file_path = \Drupal::service('file_system')->realpath('public://') . str_replace('/sites/default/files/', '/', $src);

        if (file_exists($file_path)) {
          $size = getimagesize($file_path);
          if ($size) {
            $img->setAttribute('width', $size[0]);
            $img->setAttribute('height', $size[1]);
          }
        }
      }
    }

    $result = $dom->saveHTML();
    return new FilterProcessResult($result);
  }
}

Solution 2: BigPipe Optimization

Prevent BigPipe Layout Shifts

Use Placeholders with Dimensions:

<?php

/**
 * Implements hook_preprocess_block().
 */
function mytheme_preprocess_block(&$variables) {
  // Add min-height to BigPipe placeholders
  if (isset($variables['elements']['#lazy_builder'])) {
    $variables['attributes']['style'] = 'min-height: 200px;';
    $variables['attributes']['class'][] = 'bigpipe-placeholder';
  }
}

CSS:

.bigpipe-placeholder {
  min-height: 200px;
  /* Optionally add skeleton loader */
  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; }
}

.bigpipe-placeholder.bigpipe-placeholder-replaced {
  min-height: 0;
  background: none;
  animation: none;
}

Disable BigPipe for Critical Pages

<?php

/**
 * Implements hook_page_attachments_alter().
 */
function mytheme_page_attachments_alter(array &$attachments) {
  $route_match = \Drupal::routeMatch();

  // Disable BigPipe on landing pages
  if ($route_match->getRouteName() === 'entity.node.canonical') {
    $node = $route_match->getParameter('node');

    if ($node && $node->bundle() === 'landing_page') {
      // Remove BigPipe library
      foreach ($attachments['#attached']['library'] as $key => $library) {
        if ($library === 'big_pipe/big_pipe') {
          unset($attachments['#attached']['library'][$key]);
        }
      }
    }
  }
}

Solution 3: Font Loading Optimization

Prevent FOUT/FOIT

Use font-display: swap:

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

Preload Critical Fonts

<?php

function mytheme_page_attachments(array &$attachments) {
  $attachments['#attached']['html_head_link'][] = [
    [
      'rel' => 'preload',
      'href' => '/themes/custom/mytheme/fonts/primary-font.woff2',
      'as' => 'font',
      'type' => 'font/woff2',
      'crossorigin' => 'anonymous',
    ],
    TRUE
  ];
}

Use Font Loading API

(function() {
  'use strict';

  if ('fonts' in document) {
    // Modern browser with Font Loading API
    document.fonts.ready.then(function() {
      document.documentElement.classList.add('fonts-loaded');
    });
  } else {
    // Fallback for older browsers
    document.documentElement.classList.add('fonts-loaded');
  }
})();

CSS:

/* Use fallback font initially */
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

/* Switch to custom font when loaded */
.fonts-loaded body {
  font-family: 'CustomFont', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

Solution 4: Dynamic Content (Ads, Embeds)

Reserve Space for Ads

.ad-slot {
  min-height: 250px; /* Minimum ad height */
  min-width: 300px;  /* Minimum ad width */
  background: #f5f5f5;
  display: flex;
  align-items: center;
  justify-content: center;
}

.ad-slot::before {
  content: 'Advertisement';
  color: #999;
  font-size: 12px;
}

.ad-slot.ad-loaded::before {
  display: none;
}

Lazy Load with Placeholders

File: templates/block/block--ad.html.twig

<div class="ad-container" style="min-height: 250px;">
  <div class="ad-placeholder">
    {# Actual ad will load here #}
    {{ content }}
  </div>
</div>

Handle oEmbed Content

For media embeds (YouTube, Twitter, etc.):

<?php

/**
 * Implements hook_preprocess_media().
 */
function mytheme_preprocess_media(&$variables) {
  $media = $variables['media'];

  if ($media->bundle() === 'remote_video') {
    // Add aspect ratio container
    $variables['attributes']['class'][] = 'media-embed-container';
    $variables['attributes']['style'] = 'aspect-ratio: 16/9;';
  }
}

CSS:

.media-embed-container {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  background: #000;
}

.media-embed-container iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

/* Fallback */
@supports not (aspect-ratio: 16 / 9) {
  .media-embed-container::before {
    content: '';
    display: block;
    padding-top: 56.25%;
  }
}

Solution 5: Paragraphs Module

Add Dimensions to Paragraph Types

For image paragraphs:

Navigate to: /admin/structure/paragraphs_type/[type]/display

Configure image field:

  • Format: Responsive image (with dimensions)
  • Image style: Use styles with explicit dimensions

Prevent Dynamic Height Changes

<?php

/**
 * Implements hook_preprocess_paragraph().
 */
function mytheme_preprocess_paragraph(&$variables) {
  $paragraph = $variables['paragraph'];

  // Add min-height based on paragraph type
  $min_heights = [
    'hero' => '500px',
    'image_text' => '300px',
    'call_to_action' => '200px',
  ];

  $type = $paragraph->bundle();
  if (isset($min_heights[$type])) {
    $variables['attributes']['style'] = 'min-height: ' . $min_heights[$type] . ';';
  }
}

Solution 6: AJAX Views

Reserve Space for Views

<?php

/**
 * Implements hook_preprocess_views_view().
 */
function mytheme_preprocess_views_view(&$variables) {
  $view = $variables['view'];

  // Add min-height to prevent shifts
  if ($view->id() === 'products' && $view->current_display === 'block_1') {
    $variables['attributes']['style'] = 'min-height: 600px;';
  }
}

Or use JavaScript:

(function (Drupal, once) {
  'use strict';

  Drupal.behaviors.viewsAjaxNoShift = {
    attach: function (context, settings) {
      once('views-no-shift', '.view[data-drupal-views-ajax]', context).forEach(function(view) {
        // Store original height before AJAX update
        var originalHeight = view.offsetHeight;

        // Listen for AJAX events
        view.addEventListener('views-ajax-start', function() {
          // Set explicit height to prevent shift
          view.style.minHeight = originalHeight + 'px';
        });

        view.addEventListener('views-ajax-end', function() {
          // Remove explicit height after content loads
          setTimeout(function() {
            view.style.minHeight = '';
          }, 100);
        });
      });
    }
  };

})(Drupal, once);

Solution 7: Toolbar & Admin Menu

Fix Drupal Toolbar Shifts

The admin toolbar can cause shifts for authenticated users.

Option 1: Exclude from CLS measurement

// Only measure CLS for anonymous users
if (!document.body.classList.contains('toolbar-fixed')) {
  // Measure CLS
}

Option 2: Prevent toolbar shifts

/* Reserve space for toolbar */
body.toolbar-fixed {
  padding-top: 39px !important; /* Toolbar height */
}

body.toolbar-horizontal.toolbar-tray-open {
  padding-top: 79px !important; /* Toolbar + tray */
}

/* Adjust for mobile */
@media (max-width: 768px) {
  body.toolbar-fixed {
    padding-top: 39px !important;
  }
}

For EU Cookie Compliance module:

/* Reserve space for cookie banner */
body.eu-cookie-compliance-popup-open {
  padding-bottom: 100px; /* Banner height */
}

#sliding-popup {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  /* Don't use transform or animate height */
}

Load banner early:

<?php

function mytheme_page_attachments(array &$attachments) {
  // Load cookie banner in <head> to prevent shift
  $attachments['#attached']['library'][] = 'eu_cookie_compliance/eu_cookie_compliance';

  // Set weight to load early
  $attachments['#attached']['library_weight'] = -100;
}

Testing & Monitoring

Measure CLS Locally

Using Lighthouse:

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

Using Web Vitals library:

import {getCLS} from 'web-vitals';

getCLS(function(metric) {
  console.log('CLS:', metric.value);

  // Send to analytics
  gtag('event', 'web_vitals', {
    event_category: 'Web Vitals',
    event_label: 'CLS',
    value: Math.round(metric.value * 1000),
    metric_id: metric.id,
    metric_delta: metric.delta,
    non_interaction: true,
  });
});

Real User Monitoring

Install RUM module:

composer require drupal/rum_drupal
drush en rum_drupal -y

Or implement custom RUM:

// Track CLS for real users
(function() {
  if (!('PerformanceObserver' in window)) return;

  let clsValue = 0;
  let clsEntries = [];

  const observer = new PerformanceObserver(function(list) {
    for (const entry of list.getEntries()) {
      if (!entry.hadRecentInput) {
        clsValue += entry.value;
        clsEntries.push(entry);
      }
    }
  });

  observer.observe({type: 'layout-shift', buffered: true});

  // Send on page hide
  window.addEventListener('visibilitychange', function() {
    if (document.visibilityState === 'hidden') {
      // Send to your analytics
      navigator.sendBeacon('/analytics/cls', JSON.stringify({
        cls: clsValue,
        entries: clsEntries.length
      }));
    }
  });
})();

Quick Wins Checklist

  • ✅ Add width/height to all images
  • ✅ Use aspect-ratio CSS for containers
  • ✅ Set font-display: swap
  • ✅ Reserve space for ads
  • ✅ Add min-height to BigPipe placeholders
  • ✅ Fix embed aspect ratios
  • ✅ Prevent toolbar shifts
  • ✅ Load fonts early
  • ✅ Fix WYSIWYG image dimensions
  • ✅ Test with Web Vitals extension

Debug CLS Issues

Identify Shifting Elements

// Log all layout shifts
(function() {
  if (!('PerformanceObserver' in window)) return;

  const observer = new PerformanceObserver(function(list) {
    for (const entry of list.getEntries()) {
      console.log('Layout Shift:', {
        value: entry.value,
        hadRecentInput: entry.hadRecentInput,
        sources: entry.sources
      });

      // Highlight shifting elements
      if (entry.sources) {
        entry.sources.forEach(function(source) {
          if (source.node) {
            source.node.style.outline = '2px solid red';
            setTimeout(function() {
              source.node.style.outline = '';
            }, 2000);
          }
        });
      }
    }
  });

  observer.observe({type: 'layout-shift', buffered: true});
})();

Resources


Next Steps

// SYS.FOOTER