Fix CLS Issues on Shopware (Layout Shift) | OpsBlu Docs

Fix CLS Issues on Shopware (Layout Shift)

Stabilize Shopware layouts by sizing product media thumbnails, preloading storefront fonts, and constraining CMS Shopping Experience blocks.

Cumulative Layout Shift (CLS) measures visual stability. Unexpected layout shifts create poor user experience and hurt conversions.

Target: CLS under 0.1 Good: Under 0.1 | Needs Improvement: 0.1-0.25 | Poor: Over 0.25

General Guide: See global CLS guide for universal concepts and fixes.

Shopware-Specific CLS Issues

1. Product Images Without Dimensions

The most common CLS issue in Shopware stores is product images loading without explicit dimensions.

Problem: Images load without width/height, causing layout shift when dimensions are calculated.

Diagnosis:

  • Run PageSpeed Insights
  • Check "Avoid large layout shifts" section
  • Look for image elements in the list

Solutions:

A. Always Set Image Dimensions

In product listing templates:

{# src/Resources/views/storefront/component/product/card/box-standard.html.twig #}
{% block component_product_box_image %}
    <div class="product-image-wrapper">
        {% if product.cover %}
            <img
                src="{{ product.cover.media.url }}"
                alt="{{ product.cover.media.alt }}"
                width="{{ product.cover.media.metaData.width }}"
                height="{{ product.cover.media.metaData.height }}"
                loading="lazy"
                class="product-image">
        {% endif %}
    </div>
{% endblock %}

B. Use Aspect Ratio Containers

For responsive images with consistent aspect ratio:

/* In your theme CSS */
.product-image-wrapper {
    position: relative;
    width: 100%;
    padding-bottom: 100%; /* 1:1 aspect ratio */
    overflow: hidden;
}

.product-image {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}
<div class="product-image-wrapper">
    <img
        src="{{ product.cover.media.url }}"
        alt="{{ product.cover.media.alt }}"
        loading="lazy"
        class="product-image">
</div>

C. Use CSS aspect-ratio Property

Modern browsers support the aspect-ratio property:

.product-image {
    aspect-ratio: 1 / 1;
    width: 100%;
    height: auto;
    object-fit: cover;
}

For product detail page image galleries:

{% block page_product_detail_media %}
    <div class="product-detail-media">
        {% if product.media %}
            <div class="gallery-slider" data-aspect-ratio="1">
                {% for media in product.media %}
                    <div class="gallery-item">
                        <img
                            src="{{ media.media.url }}"
                            alt="{{ media.media.alt }}"
                            width="{{ media.media.metaData.width }}"
                            height="{{ media.media.metaData.height }}"
                            loading="{% if loop.first %}eager{% else %}lazy{% endif %}">
                    </div>
                {% endfor %}
            </div>
        {% endif %}
    </div>
{% endblock %}

2. Dynamic Content from Plugins

Plugins often inject content dynamically, causing layout shifts.

Problem: Plugin content loads after initial render, pushing existing content.

Common Culprits:

  • Review/rating widgets
  • Recommendation engines
  • Social proof notifications
  • Live chat widgets
  • Cookie consent banners
  • Newsletter popups

Solutions:

A. Reserve Space for Plugin Content

Add placeholder with appropriate height:

{# Reserve space for review widget #}
<div class="product-reviews-placeholder" style="min-height: 200px;">
    {# Plugin content loads here #}
    {{ parent() }}
</div>

B. Use Skeleton Screens

Show loading skeleton before content appears:

.review-skeleton {
    display: block;
    height: 100px;
    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; }
}
<div class="product-reviews">
    <div class="review-skeleton"></div>
    {# Plugin replaces skeleton when loaded #}
</div>

C. Load Plugin Content in Fixed Position

For overlays and popups:

/* Newsletter popup - doesn't push content */
.newsletter-popup {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 1000;
}

/* Chat widget - fixed position */
.chat-widget {
    position: fixed;
    bottom: 20px;
    right: 20px;
    z-index: 999;
}

D. Defer Non-Critical Plugins

Load plugins after page is stable:

// Wait for window load before initializing plugin
window.addEventListener('load', function() {
    setTimeout(function() {
        // Initialize non-critical plugin
        initChatWidget();
    }, 1000);
});

3. Font Loading Causing Shifts

Custom web fonts can cause text to shift when they load.

Problem: Fallback fonts have different metrics than web fonts.

Diagnosis:

  • Text "flashes" or moves when page loads
  • CLS detected during font swap

Solutions:

A. Use font-display: swap

In @font-face declarations:

@font-face {
    font-family: 'CustomFont';
    src: url('../fonts/custom-font.woff2') format('woff2');
    font-display: swap; /* Show fallback immediately, swap when loaded */
    font-weight: 400;
    font-style: normal;
}

B. Match Fallback Font Metrics

Use similar fallback fonts:

body {
    /* Match metrics of custom font */
    font-family: 'CustomFont', 'Helvetica Neue', Arial, sans-serif;
    font-size: 16px;
    line-height: 1.5;
}

C. Preload Critical Fonts

{% block layout_head_font %}
    <link
        rel="preload"
        href="{{ asset('fonts/custom-font.woff2') }}"
        as="font"
        type="font/woff2"
        crossorigin>
{% endblock %}

D. Use System Fonts

Avoid layout shift entirely:

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
                 "Helvetica Neue", Arial, sans-serif;
}

4. Off-Canvas Cart and Navigation

Shopware's off-canvas elements can cause shifts if not handled properly.

Problem: Opening/closing off-canvas pushes or shifts content.

Solutions:

A. Ensure Off-Canvas is Fixed or Absolute

/* Off-canvas cart should not affect layout */
.offcanvas {
    position: fixed;
    top: 0;
    right: 0;
    height: 100%;
    width: 400px;
    transform: translateX(100%);
    transition: transform 0.3s ease;
    z-index: 1000;
}

.offcanvas.show {
    transform: translateX(0);
}

/* Overlay doesn't shift content */
.offcanvas-backdrop {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.5);
    z-index: 999;
}

B. Prevent Body Scroll Without Shift

When off-canvas opens, prevent scrollbar shift:

// Store scrollbar width
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;

// When opening off-canvas
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = scrollbarWidth + 'px';

// When closing
document.body.style.overflow = '';
document.body.style.paddingRight = '';

C. Use transform Instead of position Changes

/* Avoid changing position */
.cart-item {
    /* Bad - causes layout shift */
    /* position: relative → absolute */
}

/* Good - use transform */
.cart-item {
    transform: translateX(0);
    transition: transform 0.3s;
}

.cart-item.removing {
    transform: translateX(100%);
}

5. CMS Element Layout Shifts

Shopware CMS elements can cause shifts if not configured properly.

Problem: CMS blocks loading content dynamically or without dimensions.

Solutions:

A. Set Block Heights in CMS

Admin Configuration:

  1. ContentShopping Experiences
  2. Edit your layout
  3. For each block, set:
    • Minimum height (if content varies)
    • Sizing mode to "Fixed height" for consistent blocks

B. Configure Image Elements Properly

In CMS image elements:

{% block element_image %}
    <div class="cms-element-image">
        {% if element.data.media %}
            <img
                src="{{ element.data.media.url }}"
                alt="{{ element.data.media.alt }}"
                width="{{ element.data.media.metaData.width }}"
                height="{{ element.data.media.metaData.height }}"
                loading="{% if element.config.displayMode.value == 'cover' %}eager{% else %}lazy{% endif %}">
        {% endif %}
    </div>
{% endblock %}

C. Reserve Space for Slider Elements

.cms-element-image-slider {
    /* Set minimum height based on expected image height */
    min-height: 500px;
    background: #f5f5f5; /* Placeholder background */
}

@media (max-width: 767px) {
    .cms-element-image-slider {
        min-height: 300px;
    }
}

6. Lazy-Loaded Content

Problem: Content appearing late without reserved space.

Solutions:

A. Use Proper Lazy Loading Attributes

{# Images below fold - lazy load with dimensions #}
<img
    src="{{ image.url }}"
    width="{{ image.metaData.width }}"
    height="{{ image.metaData.height }}"
    loading="lazy"
    alt="{{ image.alt }}">

B. Intersection Observer with Placeholders

// Reserve space before loading
const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target;
            // Dimensions already set, so no shift
            img.src = img.dataset.src;
            observer.unobserve(img);
        }
    });
});

document.querySelectorAll('img[data-src]').forEach(img => {
    observer.observe(img);
});

7. Product Listings and Filters

Problem: Applying filters causes content to jump.

Solutions:

A. Set Minimum Height for Listing Container

.cms-element-product-listing {
    min-height: 800px; /* Prevent collapse during filter */
}

@media (max-width: 767px) {
    .cms-element-product-listing {
        min-height: 600px;
    }
}

B. Show Loading Skeleton

{% block element_product_listing_col %}
    <div class="product-listing-container">
        {# Show skeleton while filtering #}
        <div class="listing-skeleton" style="display: none;">
            {# Skeleton grid #}
        </div>

        <div class="product-listing">
            {{ parent() }}
        </div>
    </div>
{% endblock %}
// When filtering
document.querySelector('.listing-skeleton').style.display = 'grid';
document.querySelector('.product-listing').style.opacity = '0.5';

// After results load
document.querySelector('.listing-skeleton').style.display = 'none';
document.querySelector('.product-listing').style.opacity = '1';

C. Maintain Grid Structure

.product-listing-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
    gap: 20px;
    /* Grid maintains structure even when empty */
}

Problem: Cookie banner appearing late and pushing content.

Solutions:

A. Use Fixed Position

.cookie-consent-banner {
    position: fixed;
    bottom: 0;
    left: 0;
    width: 100%;
    background: #fff;
    box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
    z-index: 9999;
    /* Doesn't push content */
}

B. Load Banner Early

{% block base_body %}
    {# Load cookie banner immediately in body #}
    {% block base_cookie_consent %}
        <div class="cookie-consent-banner">
            {# Banner content #}
        </div>
    {% endblock %}

    {{ parent() }}
{% endblock %}

9. Animations and Transitions

Problem: CSS animations causing layout shifts.

Solutions:

A. Use transform and opacity Only

/* Good - doesn't trigger layout */
.fade-in {
    opacity: 0;
    transform: translateY(20px);
    transition: opacity 0.3s, transform 0.3s;
}

.fade-in.show {
    opacity: 1;
    transform: translateY(0);
}

/* Bad - triggers layout */
.slide-in {
    height: 0; /* Causes layout shift */
    transition: height 0.3s;
}

.slide-in.show {
    height: auto; /* Causes layout shift */
}

B. Reserve Space for Animated Content

.collapsible {
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.3s ease;
}

.collapsible.open {
    max-height: 500px; /* Set to maximum expected height */
}

Debugging CLS

Use Chrome DevTools

  1. Open DevTools (F12)
  2. Performance tab → Record
  3. Navigate page
  4. Stop recording
  5. Look for Experience section
  6. Layout Shifts shows what caused shifts

Layout Shift Regions

In Chrome DevTools:

  1. More toolsRendering
  2. Check Layout Shift Regions
  3. Blue highlights show elements that shifted
  4. Navigate your site to see shifts in real-time

Web Vitals Extension

  1. Install Web Vitals Chrome Extension
  2. See CLS score in real-time
  3. Click for details on shifts

Testing CLS

Tools:

  1. PageSpeed Insights - Field and lab CLS
  2. Chrome DevTools - Real-time debugging
  3. WebPageTest - Filmstrip view of shifts
  4. Web Vitals Extension - Real-time monitoring

Test Scenarios:

  • Page load (most important)
  • Interacting with filters
  • Opening cart/navigation
  • Scrolling through product listings
  • Form interactions

Quick Wins Checklist

Start here for immediate CLS improvements:

  • Set explicit width/height on all images
  • Use aspect-ratio or padding-bottom for responsive images
  • Set font-display: swap for custom fonts
  • Preload critical fonts
  • Reserve space for dynamic content (reviews, recommendations)
  • Use fixed/absolute positioning for overlays
  • Set minimum heights for content containers
  • Load cookie banner early with fixed position
  • Use transform/opacity for animations
  • Prevent scrollbar shift when opening modals
  • Set dimensions on CMS image elements
  • Test with Layout Shift Regions enabled
  • Verify off-canvas doesn't affect layout

Advanced Techniques

1. Content Visibility API

For off-screen content:

.below-fold-content {
    content-visibility: auto;
    contain-intrinsic-size: 0 500px; /* Reserve height */
}

2. Will-Change for Known Animations

.animated-element {
    will-change: transform, opacity;
}

/* Remove after animation */
.animated-element.animation-complete {
    will-change: auto;
}

3. Measure and Monitor

Implement real-user monitoring:

// Track CLS with Web Vitals library
import {getCLS} from 'web-vitals';

getCLS(console.log); // Log CLS score

Common Shopware Theme Issues

Default Storefront Theme

Generally well-optimized but check:

  • Product images have dimensions
  • Off-canvas properly positioned
  • CMS blocks have appropriate heights

Custom Themes

  • Test thoroughly before launch
  • Set explicit dimensions on all images
  • Test animations for layout shift
  • Use Chrome DevTools Layout Shift Regions

Third-Party Themes

  • Check reviews for CLS issues
  • Test demo before purchase
  • Request performance report from developer

When to Hire a Developer

Consider hiring a Shopware Expert if:

  • CLS consistently over 0.25 after fixes
  • Complex theme animations causing shifts
  • Plugin causing persistent shifts
  • Need custom theme optimization
  • CMS customizations causing issues

Find Shopware Partners: store.shopware.com/en/partners

Next Steps

For general CLS optimization strategies, see CLS Optimization Guide.