Magento CLS Optimization | Blue Frog Docs

Magento CLS Optimization

Fix Cumulative Layout Shift (CLS) issues in Magento 2 through proper image dimensions, font optimization, and dynamic content handling.

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

  1. Images without dimensions - Product images, banners
  2. Web fonts loading - FOUT (Flash of Unstyled Text)
  3. Dynamic content - Cart widget, customer sections
  4. Ads and embeds - Marketing banners, iframes
  5. Lazy-loaded content - Product listings, reviews
  6. Cookie notices - GDPR banners

Measuring CLS

1. Chrome DevTools

Performance Tab:

  1. Open DevTools (F12) > Performance
  2. Enable "Web Vitals" in settings
  3. Record page load
  4. 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" />

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 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;
}

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


Additional Resources

// SYS.FOOTER