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;
}
D. Reserve Space for Gallery Images
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:
- Content → Shopping Experiences
- Edit your layout
- 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 */
}
8. Cookie Consent Banners
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
- Open DevTools (F12)
- Performance tab → Record
- Navigate page
- Stop recording
- Look for Experience section
- Layout Shifts shows what caused shifts
Layout Shift Regions
In Chrome DevTools:
- More tools → Rendering
- Check Layout Shift Regions
- Blue highlights show elements that shifted
- Navigate your site to see shifts in real-time
Web Vitals Extension
- Install Web Vitals Chrome Extension
- See CLS score in real-time
- Click for details on shifts
Testing CLS
Tools:
- PageSpeed Insights - Field and lab CLS
- Chrome DevTools - Real-time debugging
- WebPageTest - Filmstrip view of shifts
- 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.