Cumulative Layout Shift (CLS) measures visual stability by tracking unexpected layout shifts during page load. Good CLS is critical for user experience and SEO.
Target: CLS under 0.1 Good: Under 0.1 | Needs Improvement: 0.1-0.25 | Poor: Over 0.25
For general CLS concepts, see the global CLS guide.
MODX-Specific CLS Issues
1. Images Without Dimensions
Images from TVs, chunks, or getResources without explicit dimensions cause layout shifts.
Problem: Images load and push content down as dimensions are determined.
Diagnosis:
- Run PageSpeed Insights
- Look for "Image elements do not have explicit width and height"
- Use Chrome DevTools Performance → Record page load → Look for Layout Shift events
Solutions:
A. Add Dimensions to Template Images
Always specify width and height:
<!-- Before: No dimensions -->
<img src="[[*hero_image]]" alt="[[*pagetitle]]">
<!-- After: With dimensions -->
<img
src="[[*hero_image:phpthumb=`w=1920&h=600&zc=1`]]"
width="1920"
height="600"
alt="[[*pagetitle]]"
>
B. Calculate Aspect Ratio for TVs
For variable image sizes:
<!-- Responsive with aspect ratio -->
<div style="position: relative; width: 100%; padding-bottom: 56.25%;"> <!-- 16:9 ratio -->
<img
src="[[*hero_image:phpthumb=`w=1920&h=1080&zc=1`]]"
alt="[[*pagetitle]]"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;"
>
</div>
Or use modern aspect-ratio CSS:
<img
src="[[*hero_image:phpthumb=`w=1920&h=1080&zc=1`]]"
alt="[[*pagetitle]]"
style="aspect-ratio: 16 / 9; width: 100%; height: auto; object-fit: cover;"
>
C. getResources with Image Dimensions
In chunk template used by getResources:
Chunk: blogPost.tpl
<article>
<h2>[[+pagetitle]]</h2>
<!-- Bad: No dimensions -->
<img src="[[+tv.thumbnail]]" alt="[[+pagetitle]]">
<!-- Good: With dimensions -->
<img
src="[[+tv.thumbnail:phpthumb=`w=800&h=450&zc=1`]]"
width="800"
height="450"
alt="[[+pagetitle]]"
loading="lazy"
>
[[+introtext]]
</article>
In template:
[[!getResources?
&parents=`5`
&tpl=`blogPost.tpl`
&includeTVs=`thumbnail`
&limit=`10`
]]
D. Use Placeholder Images
For images that load slowly:
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 450'%3E%3C/svg%3E"
data-src="[[*hero_image:phpthumb=`w=800&h=450&zc=1`]]"
width="800"
height="450"
alt="[[*pagetitle]]"
class="lazyload"
>
2. Dynamic Content from Snippets
Snippets that load content after page render cause layout shifts.
Problem: getResources or custom snippets loading content dynamically.
Solutions:
A. Reserve Space for Snippet Output
<!-- Bad: No reserved space -->
[[!getResources? &parents=`5` &limit=`10`]]
<!-- Better: Reserved space with min-height -->
<div style="min-height: 500px;">
[[!getResources?
&parents=`5`
&limit=`10`
&tpl=`blogPost.tpl`
]]
</div>
B. Cache Snippets to Prevent Shifts
<!-- Uncached: Loads after page, may cause shift -->
[[!getResources? &parents=`5` &limit=`10`]]
<!-- Cached: Rendered with page, no shift -->
[[getResources? &parents=`5` &limit=`10`]]
Note: Only cache when content doesn't need to be real-time.
C. Use Skeleton Screens
For uncached content that must load dynamically:
<!-- Skeleton loader -->
<div class="skeleton-container [[!getResources? &parents=`5` &limit=`1`:isempty=``:else=`hidden`]]">
<div class="skeleton-item"></div>
<div class="skeleton-item"></div>
<div class="skeleton-item"></div>
</div>
<!-- Actual content -->
<div class="content-container">
[[!getResources? &parents=`5` &limit=`3` &tpl=`blogPost.tpl`]]
</div>
<style>
.skeleton-item {
height: 200px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-bottom: 1rem;
border-radius: 8px;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.hidden { display: none; }
</style>
3. Font Loading Shifts
Custom fonts loading after render cause text reflow.
Problem: Flash of Invisible Text (FOIT) or Flash of Unstyled Text (FOUT).
Solutions:
A. Use font-display: swap
/* In template or CSS chunk */
@font-face {
font-family: 'CustomFont';
src: url('[[++assets_url]]fonts/custom-font.woff2') format('woff2');
font-display: swap; /* Show fallback immediately, swap when loaded */
font-weight: 400;
font-style: normal;
}
B. Preload Critical Fonts
<!-- In template head -->
<link
rel="preload"
href="[[++assets_url]]fonts/custom-font.woff2"
as="font"
type="font/woff2"
crossorigin
>
C. Match Fallback Font Metrics
Use similar fallback fonts to minimize shift:
body {
font-family: 'CustomFont', Arial, sans-serif;
/* Adjust line-height and letter-spacing to match custom font */
line-height: 1.5;
letter-spacing: -0.01em;
}
D. Use System Fonts
Eliminate font loading shifts entirely:
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
}
4. Ads and Embeds
Third-party content (ads, social embeds, maps) loading without reserved space.
Problem: Embedded content pushes existing content down.
Solutions:
A. Reserve Space for Embeds
<!-- YouTube embed with reserved space -->
<div style="position: relative; width: 100%; padding-bottom: 56.25%;"> <!-- 16:9 -->
<iframe
src="https://www.youtube.com/embed/VIDEO_ID"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
frameborder="0"
allowfullscreen
></iframe>
</div>
B. Set Min-Height for Ad Slots
<!-- Ad container with reserved space -->
<div class="ad-container" style="min-height: 250px; width: 300px;">
[[!adSnippet]]
</div>
C. Load Embeds on User Interaction
Prevent shifts by loading on click:
<div class="video-placeholder" style="width: 100%; padding-bottom: 56.25%; position: relative; background: #000; cursor: pointer;"
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white;">
▶ Click to load video
</div>
</div>
<script>
function loadVideo(el) {
el.innerHTML = '<iframe src="https://www.youtube.com/embed/VIDEO_ID" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;" frameborder="0" allowfullscreen></iframe>';
}
</script>
5. JavaScript-Modified Layouts
JavaScript in templates or chunks that modify DOM causes shifts.
Problem: Scripts manipulating layout after page load.
Solutions:
A. Avoid DOM Manipulation After Load
// Bad: Modifies DOM after load
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('.header').style.height = '100px';
});
// Better: Set in CSS
.header { height: 100px; }
B. Use CSS for Responsive Behavior
/* Instead of JavaScript resize listeners */
.sidebar {
width: 300px;
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
}
}
C. Calculate Dimensions Server-Side
In MODX snippet instead of client-side:
<?php
// Calculate dimensions in snippet
$imageWidth = 800;
$imageHeight = 450;
$output = '<img src="' . $image . '" width="' . $imageWidth . '" height="' . $imageHeight . '" alt="">';
return $output;
6. Uncached Snippets with Variable Output
Snippets that produce different heights on each load.
Problem: Cart counts, user info, dynamic pricing causing variable heights.
Solutions:
A. Set Minimum Heights
<div class="user-info" style="min-height: 60px;">
[[!userInfo]]
</div>
B. Use Fixed Layouts
.cart-count {
display: inline-block;
width: 24px; /* Fixed width prevents shift */
text-align: center;
}
C. Load Below Fold
Place dynamic content below viewport:
<!-- Above fold: Static content with no shifts -->
<header>[[*pagetitle]]</header>
<div class="hero">[[*hero_image]]</div>
<!-- Below fold: Dynamic content -->
<section>
[[!dynamicContent]]
</section>
7. Navigation Menus
JavaScript-based menus that render after page load.
Problem: Menu items appearing/disappearing causing layout shifts.
Solutions:
A. Server-Side Menu Generation
Use Wayfinder or pdoMenu (cached):
<!-- Cached menu, no shift -->
[[Wayfinder?
&startId=`0`
&level=`2`
&outerTpl=`navOuter`
&rowTpl=`navRow`
]]
B. Reserve Space for Menus
.nav-container {
min-height: 60px; /* Reserve space for menu */
}
.nav-menu {
/* Menu styles */
}
C. Hide Until Ready
For JavaScript menus:
.nav-menu {
opacity: 0;
transition: opacity 0.3s;
}
.nav-menu.ready {
opacity: 1;
}
// Show when ready
document.addEventListener('DOMContentLoaded', function() {
const menu = document.querySelector('.nav-menu');
// Initialize menu...
menu.classList.add('ready');
});
MODX Template Best Practices
1. Use Consistent Chunk Structures
Create chunks with fixed dimensions:
Chunk: productCard.tpl
<div class="product-card" style="height: 400px;">
<img
src="[[+tv.image:phpthumb=`w=300&h=200&zc=1`]]"
width="300"
height="200"
alt="[[+pagetitle]]"
loading="lazy"
>
<h3>[[+pagetitle]]</h3>
<p class="price">$[[+tv.price]]</p>
</div>
2. Template Variable (TV) Output Filters
Ensure consistent output:
<!-- Bad: Variable length -->
[[*description]]
<!-- Better: Truncated to consistent length -->
[[*description:ellipsis=`150`]]
3. Grid Layouts with Fixed Heights
<div class="grid" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
[[getResources?
&parents=`5`
&limit=`9`
&tpl=`productCard.tpl`
]]
</div>
CSS:
.product-card {
height: 400px; /* Fixed height prevents shifts */
overflow: hidden;
}
4. Lazy Loading Below Fold
<!-- Above fold: eager loading, no lazy -->
<img src="[[*hero_image:phpthumb=`w=1920`]]" width="1920" height="600" alt="" loading="eager">
<!-- Below fold: lazy loading -->
<img src="[[*gallery_image:phpthumb=`w=800`]]" width="800" height="600" alt="" loading="lazy">
Testing & Monitoring
Measure CLS
Chrome DevTools:
- Open DevTools (F12)
- Performance tab
- Click Record
- Reload page
- Stop recording
- Look for red Layout Shift bars in timeline
- Click to see which elements shifted
Web Vitals Extension:
- Install Web Vitals extension
- Visit page
- Check CLS score in extension popup
PageSpeed Insights:
- Test URL at pagespeed.web.dev
- Review CLS score in both Lab and Field data
- Expand to see which elements contribute
Debug Layout Shifts
Identify shifting elements:
// Add to template temporarily
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
cls += entry.value;
console.log('Layout Shift:', entry.value, entry.sources);
}
}
}).observe({type: 'layout-shift', buffered: true});
window.addEventListener('load', () => {
setTimeout(() => {
console.log('Total CLS:', cls);
}, 2000);
});
Test Different Resources
Test CLS across:
- Homepage
- Article/blog pages
- Product pages (if e-commerce)
- Pages with getResources
- Pages with dynamic snippets
Quick Wins Checklist
Immediate CLS improvements:
- Add width/height to all images in templates
- Add dimensions to images in getResources chunks
- Use
font-display: swapfor custom fonts - Reserve space for embedded content (videos, ads)
- Cache snippets that don't need real-time data
- Set min-height on containers with dynamic content
- Use aspect-ratio CSS for responsive images
- Load JavaScript with
deferto prevent DOM manipulation - Test with Chrome DevTools Performance tab
- Verify CLS < 0.1 in PageSpeed Insights
Common CLS Culprits in MODX
| Issue | Impact | Fix Priority |
|---|---|---|
| Images without dimensions | High | Highest - Add width/height |
| Uncached getResources | Medium | High - Cache when possible |
| Font loading | Medium | High - Use font-display: swap |
| Dynamic ads/embeds | High | Medium - Reserve space |
| JavaScript menus | Low | Low - Use server-side rendering |
When to Hire a Developer
Consider hiring if:
- CLS consistently over 0.25 after basic fixes
- Complex JavaScript interactions causing shifts
- Need custom lazy-loading implementation
- Template architecture needs restructuring
- Dynamic content requirements conflict with CLS goals
Find MODX developers: MODX Professional Directory
Next Steps
For general CLS optimization strategies, see CLS Optimization Guide.