Magento CLS Optimization
Cumulative Layout Shift (CLS) measures visual stability by tracking unexpected layout shifts during page load. For Magento 2 stores, common causes include images without dimensions, web fonts, and dynamic content injection. This guide provides solutions for achieving a CLS score under 0.1.
Understanding CLS in Magento
What is CLS?
Cumulative Layout Shift measures the sum of all unexpected layout shifts that occur during the entire lifespan of a page.
Google's CLS Thresholds:
- Good: ≤ 0.1
- Needs Improvement: 0.1 - 0.25
- Poor: > 0.25
Common CLS Causes in Magento
- Images without dimensions - Product images, banners
- Web fonts loading - FOUT (Flash of Unstyled Text)
- Dynamic content - Cart widget, customer sections
- Ads and embeds - Marketing banners, iframes
- Lazy-loaded content - Product listings, reviews
- Cookie notices - GDPR banners
Measuring CLS
1. Chrome DevTools
Performance Tab:
- Open DevTools (F12) > Performance
- Enable "Web Vitals" in settings
- Record page load
- Look for red "Layout Shift" bars
Layout Shift Regions:
- DevTools highlights shifted elements in purple
2. Web Vitals Extension
Install: Web Vitals Extension
- Real-time CLS measurement
- Identifies shifting elements
- Color-coded scoring
3. Lighthouse
lighthouse https://your-store.com --view --only-categories=performance
4. PageSpeed Insights
Visit: https://pagespeed.web.dev/
- Field data from real users
- Lab data simulation
- Specific shift screenshots
Image Dimension Fixes
1. Set Explicit Width and Height
Images are the #1 cause of CLS in Magento. Always specify dimensions.
Product Images
File: view/frontend/templates/product/list/item.phtml
<?php
/** @var \Magento\Catalog\Block\Product\ListProduct $block */
$product = $block->getProduct();
$imageWidth = 300;
$imageHeight = 300;
?>
<img src="<?= $block->getImage($product, 'category_page_list')->getImageUrl() ?>"
alt="<?= $block->escapeHtmlAttr($product->getName()) ?>"
width="<?= $imageWidth ?>"
height="<?= $imageHeight ?>"
class="product-image-photo" />
Product Gallery (PDP)
File: Magento_Catalog/templates/product/view/gallery.phtml
<img src="<?= $block->getImageUrl() ?>"
alt="<?= $block->escapeHtml($product->getName()) ?>"
width="<?= $block->getWidth() ?>"
height="<?= $block->getHeight() ?>"
loading="eager" /> <!-- Main image should load eagerly -->
Category Banners
File: Magento_Catalog/templates/category/image.phtml
<?php
$categoryImage = $block->getCategoryImage();
$width = 1920;
$height = 400;
?>
<img src="<?= $categoryImage ?>"
alt="<?= $block->escapeHtml($block->getCurrentCategory()->getName()) ?>"
width="<?= $width ?>"
height="<?= $height ?>"
class="category-image" />
2. CSS Aspect Ratio Boxes
For dynamic image sizes, use aspect ratio containers.
CSS:
.product-image-container {
position: relative;
width: 100%;
padding-bottom: 100%; /* 1:1 aspect ratio */
overflow: hidden;
}
.product-image-container img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* For 4:3 ratio */
.banner-container {
padding-bottom: 75%; /* 4:3 ratio */
}
/* For 16:9 ratio */
.video-container {
padding-bottom: 56.25%; /* 16:9 ratio */
}
HTML:
<div class="product-image-container">
<img src="product.jpg" alt="Product" loading="lazy" />
</div>
3. Responsive Images with Dimensions
File: view/frontend/templates/product/image_with_srcset.phtml
<?php
$smallWidth = 480;
$mediumWidth = 768;
$largeWidth = 1200;
$aspectRatio = 1; // 1:1 for product images
?>
<img src="<?= $block->getLargeImageUrl() ?>"
srcset="<?= $block->getSmallImageUrl() ?> <?= $smallWidth ?>w,
<?= $block->getMediumImageUrl() ?> <?= $mediumWidth ?>w,
<?= $block->getLargeImageUrl() ?> <?= $largeWidth ?>w"
sizes="(max-width: 480px) 100vw,
(max-width: 768px) 50vw,
33vw"
alt="<?= $block->escapeHtml($product->getName()) ?>"
width="<?= $largeWidth ?>"
height="<?= (int)($largeWidth * $aspectRatio) ?>"
class="product-image" />
Font Loading Optimization
1. Use font-display: swap
Prevents invisible text (FOIT) and reduces CLS.
File: web/css/source/_typography.less
@font-face {
font-family: 'Open Sans';
src: url('../fonts/OpenSans-Regular.woff2') format('woff2'),
url('../fonts/OpenSans-Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap; /* Show fallback font immediately */
}
@font-face {
font-family: 'Open Sans';
src: url('../fonts/OpenSans-Bold.woff2') format('woff2');
font-weight: bold;
font-style: normal;
font-display: swap;
}
2. Preload Critical Fonts
File: view/frontend/layout/default.xml
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<head>
<link rel="preload" as="font" type="font/woff2"
href="{{static url='fonts/OpenSans-Regular.woff2'}}"
crossorigin="anonymous"/>
<link rel="preload" as="font" type="font/woff2"
href="{{static url='fonts/OpenSans-Bold.woff2'}}"
crossorigin="anonymous"/>
</head>
</page>
3. Match Fallback Font Metrics
Reduce shift by matching fallback font size to web font.
CSS:
body {
font-family: 'Open Sans', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
}
/* Adjust fallback font to match Open Sans metrics */
@font-face {
font-family: 'Open Sans Fallback';
src: local('Arial');
ascent-override: 105%;
descent-override: 35%;
line-gap-override: 0%;
size-adjust: 100%;
}
body {
font-family: 'Open Sans', 'Open Sans Fallback', Arial, sans-serif;
}
4. Font Loading Strategy
File: view/frontend/templates/font-loading.phtml
<script>
(function() {
// Check if font is cached
if (sessionStorage.getItem('fontsLoaded')) {
document.documentElement.className += ' fonts-loaded';
return;
}
// Font loading detection
if ('fonts' in document) {
Promise.all([
document.fonts.load('1em Open Sans'),
document.fonts.load('bold 1em Open Sans')
]).then(function() {
document.documentElement.className += ' fonts-loaded';
sessionStorage.setItem('fontsLoaded', 'true');
});
}
})();
</script>
<style>
/* Default styling with fallback font */
body {
font-family: Arial, sans-serif;
}
/* Apply web font after loaded */
.fonts-loaded body {
font-family: 'Open Sans', Arial, sans-serif;
}
</style>
Dynamic Content Handling
1. Reserve Space for Customer Sections
Magento's private content loads asynchronously, causing shifts.
File: Magento_Customer/web/css/source/_module.less
// Reserve space for minicart
.minicart-wrapper {
min-height: 40px; // Reserve vertical space
min-width: 120px; // Reserve horizontal space
}
// Reserve space for customer name
.customer-welcome {
min-height: 30px;
min-width: 150px;
}
// Skeleton loader for cart
.minicart-wrapper.loading {
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; }
}
2. Placeholder for KnockoutJS Content
File: view/frontend/templates/checkout/cart/summary.phtml
<!-- ko if: isLoading -->
<div class="cart-summary-placeholder" style="min-height: 200px;">
<div class="skeleton-line" style="height: 20px; margin-bottom: 10px;"></div>
<div class="skeleton-line" style="height: 20px; margin-bottom: 10px; width: 80%;"></div>
<div class="skeleton-line" style="height: 40px; margin-top: 20px;"></div>
</div>
<!-- /ko -->
<!-- ko ifnot: isLoading -->
<div class="cart-summary">
<!-- Actual content -->
</div>
<!-- /ko -->
CSS for Skeleton:
.skeleton-line {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4px;
}
3. Static Placeholders for RequireJS Components
File: view/frontend/templates/product/compare.phtml
<div class="block-compare" style="min-height: 50px;">
<noscript>
<!-- Fallback content with proper dimensions -->
<div style="height: 50px;"></div>
</noscript>
<div data-bind="scope: 'compareProducts'">
<!-- ko template: getTemplate() --><!-- /ko -->
</div>
</div>
<script type="text/x-magento-init">
{
"[data-bind=\"scope: 'compareProducts'\"]": {
"Magento_Ui/js/core/app": {
"components": {
"compareProducts": {
"component": "Magento_Catalog/js/view/compare-products"
}
}
}
}
}
</script>
Cookie Banner / GDPR Notice
Cookie notices often cause CLS. Reserve space or use better positioning.
Option 1: Reserve Space
CSS:
body {
padding-bottom: 80px; /* Reserve space for cookie banner */
}
.cookie-notice {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 80px;
background: #000;
color: #fff;
z-index: 9999;
}
/* Remove padding after acceptance */
body.cookie-accepted {
padding-bottom: 0;
}
Option 2: Overlay (No Layout Shift)
CSS:
.cookie-notice {
position: fixed;
bottom: 20px;
left: 20px;
right: 20px;
max-width: 500px;
background: #000;
color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 9999;
transform: translateY(0); /* No shift, just appears */
}
Product Listings & Carousels
1. Fixed Grid Layout
CSS:
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.product-item {
min-height: 400px; /* Reserve space for product card */
}
.product-item-photo {
aspect-ratio: 1 / 1;
width: 100%;
height: auto;
}
2. Carousel with Fixed Dimensions
File: view/frontend/templates/product/carousel.phtml
<div class="product-carousel" style="min-height: 450px;">
<div class="slick-slider" data-mage-init='{"slick": {...}}'>
<?php foreach ($products as $product): ?>
<div class="carousel-item" style="height: 450px;">
<img src="<?= $product->getImageUrl() ?>"
alt="<?= $product->getName() ?>"
width="300"
height="300" />
<h3><?= $product->getName() ?></h3>
</div>
<?php endforeach; ?>
</div>
</div>
Ads and Third-Party Embeds
1. Reserve Space for Ads
CSS:
.ad-banner {
min-height: 250px; /* Standard banner height */
min-width: 300px;
background: #f0f0f0;
position: relative;
}
.ad-banner iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
2. Lazy Load with Placeholder
File: view/frontend/templates/ads/banner.phtml
<div class="ad-container" style="aspect-ratio: 16 / 9; min-height: 250px;">
<div class="ad-placeholder" style="width: 100%; height: 100%; background: #f0f0f0;">
<!-- Placeholder content -->
</div>
<div class="ad-content" data-bind="afterRender: loadAd">
<!-- Ad will load here -->
</div>
</div>
Page Builder Content
1. Set Minimum Heights
File: Magento_PageBuilder/web/css/source/_module.less
.pagebuilder-column {
min-height: 100px; // Prevent collapse before content loads
}
.pagebuilder-banner {
position: relative;
img {
width: 100%;
height: auto;
display: block;
}
}
.pagebuilder-slider {
min-height: 500px; // Reserve space for slider
}
Mobile-Specific Fixes
1. Touch-Friendly Spacing
CSS:
@media (max-width: 768px) {
/* Ensure adequate touch targets */
.product-item-actions a {
min-height: 44px; /* Apple's recommended minimum */
padding: 12px 16px;
}
/* Fixed mobile navigation */
.nav-sections {
position: sticky;
top: 0;
z-index: 100;
}
}
2. Prevent Viewport Shifts
Meta Tag:
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">
Testing Tools
1. Layout Shift GIF Generator
Chrome Extension: Layout Shift GIF Generator
- Captures layout shifts as GIF
- Identifies specific elements causing shifts
2. Lighthouse CI
Automate CLS testing:
npm install -g @lhci/cli
# Run audit
lhci autorun --collect.url=https://your-store.com/
3. WebPageTest
Detailed filmstrip showing shifts:
https://www.webpagetest.org/
Magento-Specific Solutions
1. Disable Problematic Modules
Some modules cause shifts:
# Temporarily disable to test
php bin/magento module:disable Vendor_Module
php bin/magento cache:flush
2. Optimize Customer Data Sections
File: etc/frontend/sections.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- Only invalidate sections when necessary -->
<action name="catalog/product/view">
<!-- Don't invalidate on every product view -->
</action>
</config>
3. Reduce KnockoutJS Bindings
Minimize dynamic bindings that cause re-renders:
Before:
<span data-bind="text: productName"></span>
After (if value doesn't change):
<span><?= $product->getName() ?></span>
Quick Wins Checklist
- Add width/height to all images
- Set font-display: swap for web fonts
- Preload critical fonts
- Reserve space for customer sections (minicart, welcome message)
- Use CSS aspect ratio boxes for dynamic images
- Fix cookie banner positioning
- Set minimum heights for dynamic containers
- Optimize carousel/slider dimensions
- Reserve space for ads
- Remove unnecessary KnockoutJS bindings
- Test on mobile devices
Monitoring & Prevention
1. Real User Monitoring (RUM)
Implement CLS tracking:
File: view/frontend/templates/cls-monitoring.phtml
<script>
let clsScore = 0;
let clsEntries = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsScore += entry.value;
clsEntries.push(entry);
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
// Send to analytics
window.addEventListener('beforeunload', () => {
if (clsScore > 0.1) {
// Log or send to analytics
console.log('CLS Score:', clsScore);
console.log('Shifts:', clsEntries);
}
});
</script>
2. Continuous Integration Testing
Add CLS checks to CI/CD:
# Lighthouse CI configuration
# .lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['https://your-store.com/'],
},
assert: {
assertions: {
'cumulative-layout-shift': ['error', {maxNumericValue: 0.1}],
},
},
},
};
Next Steps
- LCP Optimization - Fix loading performance
- Tracking Issues - Debug analytics
- Web Vitals Guide - Learn more about Core Web Vitals
Additional Resources
- Debug Layout Shifts - Google's debugging guide
- CLS Optimization - Optimization techniques
- Magento Frontend Guide - Magento development docs
- CSS Aspect Ratio - Modern CSS aspect ratio