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:
- Open DevTools (F12)
- Go to Performance tab
- Check ✅ Experience in settings
- Click Record
- Reload page
- Look for red Layout Shift bars
- 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;
}
}
Solution 8: Cookie Consent Banners
Prevent Cookie Banner Shifts
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
- Web Vitals Chrome Extension
- Layout Shift GIF Generator
- Cumulative Layout Shift Guide
- Drupal Performance