Learn how to prevent and fix Cumulative Layout Shift (CLS) in Craft CMS by properly sizing images, handling dynamic content, optimizing Matrix blocks, and implementing best practices.
Understanding CLS in Craft CMS
CLS measures visual stability by tracking unexpected layout shifts during page load. Common causes in Craft CMS:
- Images without dimensions from asset fields
- Dynamic Matrix blocks loading asynchronously
- Web fonts causing text reflow
- Ads and embeds in content
- Lazy-loaded content without placeholders
Target: CLS score should be less than 0.1 for good user experience.
Diagnosing CLS Issues
Step 1: Identify Layout Shifts
Use Chrome DevTools Performance tab:
// Run in browser console to track layout shifts
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
cls += entry.value;
console.log('Layout shift detected:', entry);
console.log('Current CLS:', cls);
console.log('Shifted elements:', entry.sources);
}
}
}).observe({type: 'layout-shift', buffered: true});
Step 2: Use Lighthouse
lighthouse https://yoursite.com --only-categories=performance --view
Image Dimension Issues
Problem: Images Without Width/Height
{# BAD - No dimensions, causes layout shift #}
{% set image = entry.featuredImage.one() %}
<img src="{{ image.url }}" alt="{{ image.title }}">
Solution: Always Specify Dimensions
{# GOOD - Explicit dimensions prevent layout shift #}
{% set image = entry.featuredImage.one() %}
<img src="{{ image.getUrl({ width: 800 }) }}"
alt="{{ image.title }}"
width="800"
height="{{ (image.height / image.width * 800)|round }}"
loading="lazy">
Dynamic Aspect Ratio Calculation
{# Calculate aspect ratio to preserve layout #}
{% set image = entry.featuredImage.one() %}
{% set targetWidth = 800 %}
{% set aspectRatio = image.height / image.width %}
{% set targetHeight = (targetWidth * aspectRatio)|round %}
<img src="{{ image.getUrl({ width: targetWidth }) }}"
alt="{{ image.title }}"
width="{{ targetWidth }}"
height="{{ targetHeight }}"
loading="lazy">
Using aspect-ratio CSS Property
{% set image = entry.featuredImage.one() %}
<div class="image-container" style="aspect-ratio: {{ image.width }} / {{ image.height }};">
<img src="{{ image.getUrl({ width: 800 }) }}"
alt="{{ image.title }}"
loading="lazy">
</div>
<style>
.image-container {
width: 100%;
overflow: hidden;
}
.image-container img {
width: 100%;
height: auto;
display: block;
}
</style>
Responsive Images with Dimensions
Proper Responsive Image Implementation
{% set image = entry.featuredImage.one() %}
{% set aspectRatio = image.height / image.width %}
{# Define responsive breakpoints #}
{% set sizes = {
mobile: 640,
tablet: 1024,
desktop: 1920
} %}
<picture>
<source media="(min-width: 1024px)"
srcset="{{ image.getUrl({ width: sizes.desktop }) }}"
width="{{ sizes.desktop }}"
height="{{ (sizes.desktop * aspectRatio)|round }}">
<source media="(min-width: 768px)"
srcset="{{ image.getUrl({ width: sizes.tablet }) }}"
width="{{ sizes.tablet }}"
height="{{ (sizes.tablet * aspectRatio)|round }}">
<img src="{{ image.getUrl({ width: sizes.mobile }) }}"
alt="{{ image.title }}"
width="{{ sizes.mobile }}"
height="{{ (sizes.mobile * aspectRatio)|round }}"
loading="lazy">
</picture>
Art Direction with Maintained Aspect Ratios
{% set heroImage = entry.heroImage.one() %}
{# Different crops for different devices #}
{% set transforms = {
mobile: { width: 640, height: 960, mode: 'crop', position: 'center-center' },
tablet: { width: 1024, height: 768, mode: 'crop', position: 'center-center' },
desktop: { width: 1920, height: 1080, mode: 'crop', position: 'center-center' }
} %}
<picture>
<source media="(min-width: 1024px)"
srcset="{{ heroImage.getUrl(transforms.desktop) }}"
width="{{ transforms.desktop.width }}"
height="{{ transforms.desktop.height }}">
<source media="(min-width: 768px)"
srcset="{{ heroImage.getUrl(transforms.tablet) }}"
width="{{ transforms.tablet.width }}"
height="{{ transforms.tablet.height }}">
<img src="{{ heroImage.getUrl(transforms.mobile) }}"
alt="{{ heroImage.title }}"
width="{{ transforms.mobile.width }}"
height="{{ transforms.mobile.height }}">
</picture>
Matrix Field Layout Shifts
Problem: Dynamic Matrix Blocks
Matrix blocks loading without reserved space:
{# BAD - No height reservation #}
{% for block in entry.contentBlocks.all() %}
{% switch block.type.handle %}
{% case 'imageBlock' %}
{% set image = block.image.one() %}
<img src="{{ image.url }}" alt="{{ image.title }}">
{% endswitch %}
{% endfor %}
Solution: Reserve Space for Matrix Blocks
{# GOOD - Proper dimensions for all block types #}
{% for block in entry.contentBlocks.all() %}
{% switch block.type.handle %}
{% case 'imageBlock' %}
{% set image = block.image.one() %}
<div class="matrix-image-block">
<img src="{{ image.getUrl({ width: 1200 }) }}"
alt="{{ image.title }}"
width="1200"
height="{{ (image.height / image.width * 1200)|round }}"
loading="lazy">
</div>
{% case 'textBlock' %}
{# Reserve minimum height for text blocks #}
<div class="matrix-text-block" style="min-height: 200px;">
{{ block.text }}
</div>
{% case 'videoBlock' %}
{# 16:9 aspect ratio container #}
<div class="matrix-video-block" style="aspect-ratio: 16 / 9;">
{{ block.videoEmbed }}
</div>
{% case 'galleryBlock' %}
<div class="matrix-gallery-block">
{% for image in block.images.all() %}
<img src="{{ image.getUrl({ width: 400 }) }}"
alt="{{ image.title }}"
width="400"
height="{{ (image.height / image.width * 400)|round }}"
loading="lazy">
{% endfor %}
</div>
{% endswitch %}
{% endfor %}
Skeleton Placeholders for Matrix Blocks
{# Use skeleton loaders while content loads #}
<style>
.skeleton {
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; }
}
</style>
{% for block in entry.contentBlocks.all() %}
{% switch block.type.handle %}
{% case 'imageBlock' %}
{% set image = block.image.one() %}
<div class="image-wrapper" style="aspect-ratio: {{ image.width }} / {{ image.height }};">
<div class="skeleton" style="width: 100%; height: 100%;"></div>
<img src="{{ image.getUrl({ width: 800 }) }}"
alt="{{ image.title }}"
width="800"
height="{{ (image.height / image.width * 800)|round }}"
loading="lazy"
</div>
{% endswitch %}
{% endfor %}
Font Loading Optimization
Problem: FOUT/FOIT Causing Layout Shift
Fonts loading late can cause text to reflow:
/* BAD - Can cause FOIT (Flash of Invisible Text) */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
}
Solution: font-display Strategy
<head>
<style>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap; /* Prevents invisible text */
font-stretch: 25% 151%;
}
/* Match fallback font metrics */
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
</style>
{# Preload critical fonts #}
<link rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin>
</head>
Using Fallback Font Metrics
/* Match fallback font to custom font metrics */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-display: swap;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
size-adjust: 107%;
}
body {
font-family: 'Inter', 'Arial', sans-serif;
}
Font Loading API
<script>
// Load fonts asynchronously without layout shift
if ('fonts' in document) {
Promise.all([
document.fonts.load('400 1em Inter'),
document.fonts.load('700 1em Inter')
]).then(function() {
document.documentElement.classList.add('fonts-loaded');
});
}
</script>
<style>
/* Before fonts load */
body {
font-family: system-ui, -apple-system, sans-serif;
}
/* After fonts load */
.fonts-loaded body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
</style>
Dynamic Content Handling
Entries with Variable Height Content
{# Reserve space for dynamic content #}
{% for entry in craft.entries().section('blog').limit(10).all() %}
<article class="blog-card">
{% set featuredImage = entry.featuredImage.one() %}
{# Image with dimensions #}
{% if featuredImage %}
<img src="{{ featuredImage.getUrl({ width: 400 }) }}"
alt="{{ featuredImage.title }}"
width="400"
height="{{ (featuredImage.height / featuredImage.width * 400)|round }}">
{% else %}
{# Placeholder with same dimensions #}
<div class="placeholder" style="width: 400px; height: 300px; background: #f0f0f0;"></div>
{% endif %}
{# Text content with min-height #}
<div class="content" style="min-height: 150px;">
<h2>{{ entry.title }}</h2>
<p>{{ entry.summary|truncate(120) }}</p>
</div>
</article>
{% endfor %}
Lazy-Loaded Content Sections
{# Reserve space for lazy-loaded sections #}
<div id="related-products" style="min-height: 400px;">
<!-- Content loaded via AJAX -->
</div>
<script>
// Load content without layout shift
fetch('/api/related-products')
.then(response => response.text())
.then(html => {
document.getElementById('related-products').innerHTML = html;
// Remove min-height after content loads
document.getElementById('related-products').style.minHeight = 'auto';
});
</script>
Ads and Third-Party Embeds
Reserve Space for Ad Units
{# Fixed-size ad container #}
<div class="ad-container" style="width: 300px; height: 250px; background: #f5f5f5;">
<!-- Ad code -->
{{ entry.adCode|raw }}
</div>
Responsive Ad Containers
{# Responsive ad with aspect ratio #}
<div class="ad-wrapper" style="aspect-ratio: 16 / 9; max-width: 728px;">
{{ entry.responsiveAd|raw }}
</div>
YouTube/Video Embeds
{# Embed with reserved aspect ratio #}
{% set videoUrl = block.videoUrl %}
<div class="video-embed" style="position: relative; padding-bottom: 56.25%; height: 0;">
<iframe src="{{ videoUrl }}"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
frameborder="0"
allowfullscreen></iframe>
</div>
Entry List/Grid Layouts
Consistent Card Heights
{% set entries = craft.entries().section('blog').limit(12).all() %}
<div class="entry-grid">
{% for entry in entries %}
<article class="entry-card">
{# Fixed aspect ratio for images #}
{% set image = entry.featuredImage.one() %}
<div class="card-image" style="aspect-ratio: 16 / 9;">
{% if image %}
<img src="{{ image.getUrl({ width: 400, height: 225, mode: 'crop' }) }}"
alt="{{ image.title }}"
width="400"
height="225"
loading="lazy">
{% endif %}
</div>
{# Fixed height for content #}
<div class="card-content" style="min-height: 200px;">
<h3>{{ entry.title }}</h3>
<p>{{ entry.summary|truncate(120) }}</p>
</div>
</article>
{% endfor %}
</div>
<style>
.entry-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
}
.entry-card {
display: flex;
flex-direction: column;
}
.card-image {
width: 100%;
overflow: hidden;
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
Commerce Product Grids
Consistent Product Card Layout
{% set products = craft.products().limit(12).all() %}
<div class="product-grid">
{% for product in products %}
{% set variant = product.defaultVariant %}
{% set image = product.featuredImage.one() %}
<div class="product-card">
{# Fixed aspect ratio product image #}
<div class="product-image" style="aspect-ratio: 1 / 1;">
{% if image %}
<img src="{{ image.getUrl({ width: 400, height: 400, mode: 'crop' }) }}"
alt="{{ product.title }}"
width="400"
height="400"
loading="lazy">
{% endif %}
</div>
{# Fixed height content area #}
<div class="product-info" style="min-height: 100px;">
<h3>{{ product.title }}</h3>
<p class="price">{{ variant.price|currency(cart.currency) }}</p>
</div>
</div>
{% endfor %}
</div>
<style>
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 2rem;
}
.product-image {
width: 100%;
overflow: hidden;
background: #f5f5f5;
}
.product-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
Monitoring CLS
Real User Monitoring
<script>
// Track CLS for analytics
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
cls += entry.value;
}
}
}).observe({type: 'layout-shift', buffered: true});
// Send to analytics on page unload
window.addEventListener('pagehide', function() {
gtag('event', 'web_vitals', {
event_category: 'Web Vitals',
event_label: 'CLS',
value: Math.round(cls * 1000),
metric_id: 'cls',
metric_value: cls,
non_interaction: true
});
});
</script>
Development Debugging
{% if craft.app.config.general.devMode %}
<script>
// Visual debugging for layout shifts
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
cls += entry.value;
console.group('Layout Shift Detected');
console.log('Shift Value:', entry.value);
console.log('Current CLS:', cls);
console.log('Affected Elements:', entry.sources);
console.groupEnd();
// Highlight shifted elements
entry.sources?.forEach(source => {
if (source.node) {
source.node.style.outline = '3px solid red';
setTimeout(() => {
source.node.style.outline = '';
}, 2000);
}
});
}
}
}).observe({type: 'layout-shift', buffered: true});
</script>
{% endif %}
Quick Wins Checklist
- Add explicit width/height to all images
- Use aspect-ratio CSS for responsive images
- Reserve space for Matrix blocks
- Implement font-display: swap for web fonts
- Preload critical fonts
- Add min-height to dynamic content areas
- Use fixed aspect ratios for video embeds
- Reserve space for ads and third-party content
- Ensure consistent card heights in grids
- Use skeleton loaders for async content
- Match fallback font metrics
- Avoid injecting content above existing content
Next Steps
- LCP Optimization - Improve Largest Contentful Paint
- Troubleshooting Overview - General Craft CMS troubleshooting
- Events Not Firing - Fix tracking issues