Fix CLS in Drupal
Drupal's CLS problem is a stack of small omissions that add up. The Responsive Image module writes width and height on source elements but omits them on the fallback img tag, so hero images shift the H1 down 200-400 ms after paint. Media embeds and iframes ship without aspect-ratio CSS, so YouTube and Vimeo iframes resize on load and shift every paragraph after them. Google Fonts arrive with metrics that differ from the fallback so a FOUT swap jumps every heading. BigPipe placeholders reserve zero height, so cart and personalization fragments push content down when they stream in. Cookie banners inject via JS 300-800 ms after LCP and push the whole page down by 60-120 px. On a stock Drupal 10 install these five sources routinely deliver a CLS p75 of 0.28. Fixed properly, the same site lands under 0.05. This guide walks through the five moves that eliminate Drupal CLS in production.
Expected results
Before
0.28
CLS p75 (Poor) -- Responsive Image fallback missing width/height, media embeds without aspect-ratio, Google Fonts FOUT swap, BigPipe placeholders with zero height, cookie banner injected via JS after LCP
After
0.04
CLS p75 (Good) -- width/height on all img output, aspect-ratio on embeds, self-hosted fonts with size-adjust override, BigPipe placeholders with reserved min-height, pre-sized cookie banner container
Step-by-step fix
Enforce width and height attributes on Responsive Image module output
Drupal's Responsive Image module emits a picture element with multiple sources but omits width and height on the fallback img tag when the style uses scale-and-crop or scale-only effects. The browser cannot reserve the correct layout box until the intrinsic dimensions download, so hero images drop in 200-400 ms later and push the H1 and paragraph below them -- a 0.15 to 0.25 CLS shift per pageview.
<?php
// modules/custom/mymodule/mymodule.module
use Drupal\responsive_image\Entity\ResponsiveImageStyle;
use Drupal\image\Entity\ImageStyle;
/**
* Implements hook_preprocess_responsive_image().
*
* Ensures the fallback img inside the picture element always carries
* explicit width and height attributes so the browser reserves the
* layout box before the image bytes arrive.
*/
function mymodule_preprocess_responsive_image(array &$variables) {
$rsi = ResponsiveImageStyle::load($variables['responsive_image_style_id'] ?? '');
if (!$rsi) {
return;
}
$fallback_id = $rsi->getFallbackImageStyle();
$style = ImageStyle::load($fallback_id);
if (!$style) {
return;
}
// Compute the fallback dimensions from the style's effects.
$dims = ['width' => $variables['width'] ?? null, 'height' => $variables['height'] ?? null];
$style->transformDimensions($dims, $variables['uri'] ?? '');
if (!empty($dims['width']) && !empty($dims['height'])) {
$variables['attributes']['width'] = (int) $dims['width'];
$variables['attributes']['height'] = (int) $dims['height'];
// Also set on the img template's own attributes bag.
$variables['img_element']['#attributes']['width'] = (int) $dims['width'];
$variables['img_element']['#attributes']['height'] = (int) $dims['height'];
}
}
# Every img inside a picture must carry width and height
$ curl -s https://example.com/article/hello | \
xmllint --html --xpath '//picture/img[not(@width) or not(@height)]' - 2>/dev/null
# Expect: (empty). Any output = a fallback missing dimensions -- fix its style.
# Sample the first article's hero
$ curl -s https://example.com/article/hello | \
grep -oE '<img[^>]*>' | head -3
# expected: <img src="..." width="1200" height="675" alt="..." />
Add aspect-ratio CSS to media embeds and iframes
The media_embed filter and oembed_provider wrap YouTube, Vimeo, and Twitter embeds in a container div but do not set an aspect ratio. On mobile the iframe resizes on load and shifts every paragraph after it. Add a wrapper class and set aspect-ratio in CSS so the container reserves the layout box before the embed loads.
{# themes/custom/mytheme/templates/media-oembed-iframe.html.twig #}
{#
The default oembed template renders a bare iframe. Wrap it in a
div that CSS can size via aspect-ratio, keyed off the media type.
#}
<div class="media-embed media-embed--{{ media.type|clean_class }}">
{{ iframe }}
</div>
/* All media embeds get a default 16/9 box before the iframe loads. */
.media-embed {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: var(--color-surface-2, #f5f5f5);
}
.media-embed > iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
/* Provider-specific overrides. */
.media-embed--twitter,
.media-embed--x { aspect-ratio: 1 / 1; }
.media-embed--instagram { aspect-ratio: 4 / 5; }
.media-embed--tiktok { aspect-ratio: 9 / 16; max-width: 340px; }
.media-embed--codepen { aspect-ratio: 4 / 3; }
/* Below-the-fold iframes should not paint until in view. */
.media-embed > iframe[loading="lazy"] { content-visibility: auto; }
Preload web fonts and use size-adjust to prevent FOUT shifts
Drupal 10 theming ships Google Fonts by default via a link tag. The fallback font's metrics differ enough from the web font that the swap causes a 0.05 to 0.12 CLS jump on paragraphs, headings, and buttons. Self-host the woff2 files, preload the two most critical weights, and add size-adjust, ascent-override, and descent-override to @font-face declarations so the fallback and web font render at identical metrics.
# themes/custom/mytheme/mytheme.libraries.yml
fonts:
version: 1.x
css:
theme:
css/fonts.css: {}
header: true
# themes/custom/mytheme/mytheme.theme
<?php
/**
* Implements hook_page_attachments().
*
* Preload the two most critical woff2 files so the browser starts
* downloading them in parallel with the CSS parse.
*/
function mytheme_page_attachments(array &$attachments) {
$preloads = [
'/themes/custom/mytheme/fonts/inter-400.woff2',
'/themes/custom/mytheme/fonts/inter-700.woff2',
];
foreach ($preloads as $href) {
$attachments['#attached']['html_head_link'][] = [
[
'rel' => 'preload',
'href' => $href,
'as' => 'font',
'type' => 'font/woff2',
'crossorigin' => 'anonymous',
],
];
}
}
/* Fallback face tuned to match Inter's metrics so the swap is invisible.
Generated with Chrome DevTools -> Rendering -> "Font override" panel. */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107.4%;
ascent-override: 90%;
descent-override: 22.4%;
line-gap-override: 0%;
}
@font-face {
font-family: 'Inter';
src: url('/themes/custom/mytheme/fonts/inter-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: optional;
}
@font-face {
font-family: 'Inter';
src: url('/themes/custom/mytheme/fonts/inter-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: optional;
}
:root {
--font-body: 'Inter', 'Inter Fallback', -apple-system, BlinkMacSystemFont, sans-serif;
}
body { font-family: var(--font-body); }
Reserve space for BigPipe placeholder fragments
BigPipe streams personalized fragments (username block, cart summary, recent activity) after the main page shell. Each fragment renders into a placeholder div whose height was zero, so when the fragment arrives it pushes everything below down by its final height. Set a min-height on every lazy_builder placeholder so the fragment streams into a pre-sized box.
<?php
// modules/custom/mymodule/mymodule.module
/**
* Implements hook_element_info_alter().
*
* Wrap #lazy_builder output in a div with a reserved min-height.
* The div is emitted synchronously in the page shell; BigPipe streams
* the fragment into it later without shifting surrounding content.
*/
function mymodule_element_info_alter(array &$types) {
if (isset($types['user_login_block'])) {
$types['user_login_block']['#pre_render'][] = 'mymodule_reserve_height_login';
}
if (isset($types['commerce_cart'])) {
$types['commerce_cart']['#pre_render'][] = 'mymodule_reserve_height_cart';
}
}
function mymodule_reserve_height_login(array $element): array {
$element['#prefix'] = '<div class="lazy-slot lazy-slot--login" style="min-height:56px">';
$element['#suffix'] = '</div>';
return $element;
}
function mymodule_reserve_height_cart(array $element): array {
$element['#prefix'] = '<div class="lazy-slot lazy-slot--cart" style="min-height:120px">';
$element['#suffix'] = '</div>';
return $element;
}
/* Contain layout and paint so a fragment stream cannot shift outside
its slot. Height was reserved server-side; contain locks it. */
.lazy-slot {
contain: layout style;
width: 100%;
overflow: hidden;
}
/* Measure your own values in DevTools; these are typical. */
.lazy-slot--login { min-height: 56px; }
.lazy-slot--cart { min-height: 120px; }
.lazy-slot--activity { min-height: 200px; }
Reserve space for cookie banners and consent forms
Cookie Content Blocker, EU Cookie Compliance, and Klaro modules inject their banner via JS after the initial paint. On first visit the banner arrives 300-800 ms after LCP and pushes the whole page down by 60-120 px, generating a huge single-shift CLS event. Server-render an empty banner div sized to the final banner height, hide it until the JS attaches content, then reveal without a size change.
{# themes/custom/mytheme/templates/html.html.twig #}
<body{{ attributes }}>
{# Reserved banner slot -- rendered in the initial HTML so the
browser accounts for it in the first layout. Height is measured
from the actual banner (78 px desktop, 120 px mobile). #}
<div id="consent-banner-slot"
class="consent-banner-slot"
aria-hidden="true"></div>
<a href="#main-content" class="visually-hidden focusable">{{ 'Skip to main content'|t }}</a>
{{ page_top }}
{{ page }}
{{ page_bottom }}
<js-placeholder token="{{ placeholder_token }}">
</body>
<style>
.consent-banner-slot {
min-height: 78px; /* desktop banner */
visibility: hidden;
background: transparent;
contain: layout style;
}
@media (max-width: 640px) {
.consent-banner-slot { min-height: 120px; } /* stacked buttons on mobile */
}
.consent-banner-slot.is-populated { visibility: visible; }
</style>
<script>
// themes/custom/mytheme/js/consent-mount.js -- 260 bytes gzipped
(function () {
var slot = document.getElementById('consent-banner-slot');
if (!slot) return;
window.addEventListener('consent:ready', function (e) {
slot.innerHTML = e.detail.html;
slot.classList.add('is-populated');
slot.removeAttribute('aria-hidden');
}, { once: true });
})();
</script>
# Run Lighthouse mobile and read the layout-shift audit
$ npx lighthouse https://example.com/ \
--only-categories=performance \
--form-factor=mobile \
--throttling.cpuSlowdownMultiplier=4 \
--output=json --output-path=./lh-cls.json
$ jq '.audits["cumulative-layout-shift"].numericValue' lh-cls.json
$ jq '.audits["layout-shift-elements"].details.items[0:5]' lh-cls.json
# before: numericValue ~0.28 with hero-image, embed, cookie-banner shifts
# after: numericValue <0.05 with no items above 0.02
Quick checklist
- Every img inside a Responsive Image picture carries width and height (verify with xmllint)
-
All media_embed and oembed iframes wrapped in
.media-embedwith provider-specific aspect-ratio - Google Fonts removed; woff2 files self-hosted, preloaded, and paired with a size-adjust fallback @font-face
-
Every #lazy_builder placeholder wrapped in a
.lazy-slotdiv with a measured min-height andcontain: layout style - Cookie banner slot server-rendered with min-height matching the actual banner; JS only fills the container, never resizes it
- Lighthouse mobile CLS under 0.05 and no individual layout-shift item above 0.02
Frequently asked questions
The three biggest CLS sources on a stock Drupal 10 site are Responsive Image module output missing width and height attributes on the fallback img tag, media_embed and oembed iframes without aspect-ratio CSS, and JS-injected cookie banners appearing 300-800 ms after LCP. BigPipe placeholder fragments and Google Fonts FOUT contribute another 0.05-0.10 CLS combined. Fixing all five sources takes a stock site from CLS 0.28 down to under 0.05.
Google rates CLS as Good under 0.10, Needs Improvement between 0.10 and 0.25, and Poor above 0.25. On a Drupal 10 site with dimensioned Responsive Image output, aspect-ratio on all media embeds, self-hosted fonts with size-adjust, BigPipe fragments in reserved boxes, and a pre-sized cookie banner container, CLS p75 typically lands between 0.02 and 0.06. Authenticated CLS is usually 0.01-0.02 higher because personalized fragments render additional content.
Only partially. The Responsive Image module writes width and height on the source elements from the derivative image style's dimensions, but the fallback img element inside the picture tag inherits from the original image style and often lacks the attributes when the fallback style is "no style" or when the style uses a scale-only effect without an explicit height. Override responsive_image_build_source_attributes or add a preprocess hook that reads the style's effects and writes intrinsic dimensions on the fallback img explicitly.
Download the woff2 files from google-webfonts-helper (gwfh.mranftl.com) or run npx google-fonts-webpack-plugin, place them under themes/custom/mytheme/fonts/, and declare @font-face in the theme's CSS with font-display: optional (or swap plus size-adjust). Remove the Google Fonts link tag from html.html.twig or from the default Drupal core CDN block. Preload the two most critical weights via a hook_page_attachments implementation that adds preload link tags to #attached['html_head'].
Only if their placeholder does not reserve height. Drupal 8 introduced BigPipe placeholders as empty divs by default. Wrap each lazy_builder placeholder in a container with an explicit min-height matching the final fragment's height (measure once with DevTools; personalization fragments are usually 40-80 px, cart summaries 100-160 px). With reserved height, BigPipe streams into a pre-sized box and generates zero CLS -- you get the LCP benefit of streaming with no layout shift cost.
CKEditor 5 writes width and height on img tags by default when authors insert an image via the media library. The problem is legacy content authored in CKEditor 4 or copy-pasted markup that omits the attributes. Add a filter to the text format pipeline that parses img tags without width/height, fetches the file entity's dimensions, and injects the attributes. Alternatively run a one-off drush script that iterates node bodies and rewrites img tags -- the second option is faster for large sites but requires a follow-up on new content.
Related resources
Complete CLS Guide
CLS thresholds, session windows, and cross-platform strategies.
FixFix TTFB in Drupal
Companion Drupal fix: Internal Page Cache, Redis backends, opcache tuning.
FixFix LCP in Drupal
Companion Drupal fix: Responsive Image module, fetchpriority preload, critical CSS.
FixFix INP in Drupal
Companion Drupal fix: library-per-page splitting, scheduler.yield, third-party facades.