This module provides a custom Text Filter that automatically adds:
loading="lazy"to:
- Images ( <img> )
- Iframes ( <iframe> )
It only applies the attribute when it is not already present, so you can still explicitly control behavior using:
loading="eager"Works for:
- Images added in the CKEditor
- Embedded iframes
This was built and tested on Drupal 11 with CKEditor 5.
I created it for a project that had around 20,000 pages with images uploaded by many editors. We needed a global solution that didn’t require editing every page manually.
Drupal core has a text filter called:
Lazy-load images
However:
- Core adds lazy loading only when width and height exist
- It works only for images
- Behavior can be inconsistent across embeds
This module:
- Adds loading="lazy" to all <img> and <iframe>
- Works even when width/height are missing
- Skips anything explicitly marked loading="eager"
To avoid conflicts:
-
Go to Configuration → Content authoring → Text formats and editors
-
Edit each text format
-
Uncheck:
- Lazy-load images
This module replaces that behavior.
Place the module in:
/modules/custom/drupal_ckeditor5_filterEnable it:
drush en drupal_ckeditor5_filter -yClear cache:
drush crGo to:
Configuration → Content authoring → Text formats and editors
Then:
-
Scroll to Filters
-
Enable:
- Lazy load iframes and images
-
Save
The filter adds:
loading="lazy"to:
- <img> elements without loading attribute
- <iframe> elements without loading attribute
If loading="eager" is already present, it is left as-is.
Input:
<img src="/example.jpg">
<iframe src="https://example.com"></iframe>Output:
<img src="/example.jpg" loading="lazy">
<iframe src="https://example.com" loading="lazy"></iframe>Unchanged example:
loading="eager"- Drupal 11 (tested)
- CKEditor 5
Instead of adding loading="lazy", you can also control loading manually.
You can store the real URL in data-src and replace it with JavaScript when you want to load it.
Updated version — convert src → data-src (for JavaScript-controlled lazy loading)
This version moves the real URL into data-src so loading can be handled fully by JavaScript.
$dom = Html::load($text);
$xpath = new \DOMXPath($dom);
// Handle <img>
foreach ($xpath->query('//img[not(@loading="eager")]') as $element) {
assert($element instanceof \DOMElement);
$src = $element->getAttribute('src');
if ($src && !$element->getAttribute('data-src')) {
$element->setAttribute('data-src', $src);
$element->removeAttribute('src'); // optional — or set placeholder
}
}
// Handle <iframe>
foreach ($xpath->query('//iframe[not(@loading="eager")]') as $element) {
assert($element instanceof \DOMElement);
$src = $element->getAttribute('src');
if ($src && !$element->getAttribute('data-src')) {
$element->setAttribute('data-src', $src);
$element->removeAttribute('src');
}
}
return Html::serialize($dom);You can then pair this with JavaScript to load elements only when they become visible:
// Lazy-load any element with data-src when it enters the viewport
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const el = entry.target;
const realSrc = el.getAttribute("data-src");
if (realSrc) {
el.setAttribute("src", realSrc);
el.removeAttribute("data-src");
}
observer.unobserve(el);
});
});
// Attach to all images/iframes that use data-src
document.querySelectorAll("[data-src]").forEach(el => observer.observe(el));Suggestions and improvements are welcome.