General Guide: See Global CLS Guide for universal concepts and fixes.
What is CLS?
Cumulative Layout Shift measures visual stability. Google recommends CLS under 0.1. ExpressionEngine generates CLS from unsized image fields in channel entries, dynamic module tag output, and late-loading add-on widgets.
ExpressionEngine-Specific CLS Causes
- Channel entry images without dimensions -- File fields and Rich Text (Wygwam) fields output
<img>tags withoutwidth/height - Dynamic channel entry listings --
{exp:channel:entries}renders variable numbers of entries at variable heights - Add-on widget injection -- third-party add-ons inject content (forms, sliders, accordions) that changes height after initial render
- AJAX-loaded content -- EE templates using AJAX to load content below-fold inject elements that shift the page
- Google Fonts in theme CSS -- EE themes load web fonts via
@importcausing FOUT
Fixes
1. Add Dimensions to Channel Entry Images
In your channel entry templates, always include width and height:
{exp:channel:entries channel="blog" limit="10"}
<article class="post-card">
{if blog_thumbnail}
<div class="post-image" style="aspect-ratio: 16/9; overflow: hidden;">
<img
src="{blog_thumbnail}"
width="400" height="225"
alt="{title}"
loading="lazy"
style="width: 100%; height: 100%; object-fit: cover;"
>
</div>
{if:else}
<div class="post-image-placeholder" style="aspect-ratio: 16/9; background: #e5e5e5;"></div>
{/if}
<h2><a href="{url_title_path='blog/entry'}">{title}</a></h2>
</article>
{/exp:channel:entries}
For Wygwam (rich text) content that contains inline images:
/* Fix images in Wygwam content output */
.entry-content img {
max-width: 100%;
height: auto;
}
.entry-content img:not([width]) {
aspect-ratio: 16 / 9;
width: 100%;
object-fit: cover;
}
2. Stabilize Channel Listing Containers
/* Consistent heights for entry listings */
.post-card {
min-height: 300px;
contain: layout;
}
/* Calendar/event listings */
.event-item {
min-height: 120px;
contain: layout;
}
/* Search results */
.search-result {
min-height: 100px;
contain: layout;
}
3. Handle Add-On Widget Loading
Wrap dynamically-loaded add-on output in reserved containers:
{!-- Form add-on --}
<div class="form-container" style="min-height: 400px; contain: layout;">
{exp:freeform:form form="contact"}
{!-- form fields --}
{/exp:freeform:form}
</div>
{!-- Slider/carousel add-on --}
<div class="slider-container" style="min-height: 500px; contain: layout; aspect-ratio: 16/9;">
{exp:mx_slider channel="slides" limit="5"}
{!-- slide content --}
{/exp:mx_slider}
</div>
4. Preload Fonts
{!-- In your layout template <head> --}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
@font-face {
font-family: 'Roboto Fallback';
src: local('Arial');
size-adjust: 100.4%;
ascent-override: 92%;
}
body { font-family: 'Roboto', 'Roboto Fallback', Arial, sans-serif; }
5. Handle Embed Content in Entries
/* Responsive embeds in channel entry content */
.entry-content iframe {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
border: 0;
}
.entry-content .twitter-tweet {
min-height: 400px;
contain: layout;
}
Measuring CLS on ExpressionEngine
- Chrome DevTools Performance tab -- look for layout-shift entries during page load
- EE Debug Output -- enable to see which templates and tags are rendering (helps correlate shifts with specific tags)
- Test pages with variable content -- blog listings with different post counts, entries with/without images
- Mobile testing -- EE themes often have responsive layouts with different CLS on mobile
Analytics Script Impact
- EE analytics are typically in template partials -- ensure they use
async/defer - Cookie consent add-ons should use fixed-position overlays
- Heatmap tools injecting overlay elements should be fixed-positioned