General Guide: See Global CLS Guide for universal concepts and fixes.
What is CLS?
Cumulative Layout Shift measures visual stability. Google recommends CLS under 0.1. Spree Commerce generates CLS from product images loading without dimensions, variant selector changes that resize the product display, Deface overrides injecting content after initial render, and storefront font loading via the asset pipeline.
Spree Commerce-Specific CLS Causes
- Product images without dimensions -- Spree's default image helpers output
<img>tags withoutwidth/heightattributes - Variant image swaps -- selecting a product variant swaps the main image, potentially changing aspect ratio
- Product listing card variability -- variable product name lengths and price formats create inconsistent card heights
- Deface-injected content -- Deface overrides that add banners, badges, or promotional content shift surrounding elements
- Flash messages -- Spree's flash notification system inserts messages at the top of the page, pushing all content down
Fixes
1. Add Dimensions to Product Images
<%# Override Spree's image partial (app/views/spree/shared/_image.html.erb) %>
<% if image %>
<div class="product-image-wrapper" style="aspect-ratio: 1/1; overflow: hidden; background: #f8f8f8;">
<img
src="<%= image.url(style) %>"
width="<%= image_width_for(style) %>"
height="<%= image_height_for(style) %>"
alt="<%= image.alt || product.name %>"
loading="<%= eager ? 'eager' : 'lazy' %>"
style="width: 100%; height: 100%; object-fit: contain;"
>
</div>
<% end %>
# Helper for consistent image dimensions
# app/helpers/spree/image_helper_decorator.rb
module Spree
module ImageHelperDecorator
def image_width_for(style)
{ mini: 48, small: 400, product: 800, large: 1200 }[style.to_sym] || 800
end
def image_height_for(style)
image_width_for(style) # Square product images
end
end
end
2. Stabilize Variant Image Swaps
// In your storefront JS
// Pre-set container dimensions before swapping variant images
document.addEventListener('spree:variant:changed', function(event) {
const imageContainer = document.querySelector('.product-image-wrapper');
if (imageContainer) {
// Lock current dimensions
const rect = imageContainer.getBoundingClientRect();
imageContainer.style.minHeight = `${rect.height}px`;
imageContainer.style.minWidth = `${rect.width}px`;
}
});
/* Consistent product image container */
.product-image-wrapper {
aspect-ratio: 1 / 1;
overflow: hidden;
contain: layout;
background: #f8f8f8;
}
.product-image-wrapper img {
width: 100%;
height: 100%;
object-fit: contain;
transition: opacity 0.2s ease;
}
3. Fix Product Listing Card Heights
/* Consistent product card layout */
.product-card {
min-height: 380px;
contain: layout;
display: flex;
flex-direction: column;
}
.product-card .product-image {
aspect-ratio: 1 / 1;
overflow: hidden;
flex-shrink: 0;
}
/* Clamp product names */
.product-card .product-name {
max-height: 3em;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* Fixed-width price display */
.product-card .price {
font-variant-numeric: tabular-nums;
min-height: 1.5em;
}
4. Fix Flash Message CLS
/* Flash messages should overlay, not push content */
.flash {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
transform: translateY(-100%);
transition: transform 0.3s ease;
}
.flash.visible {
transform: translateY(0);
}
// Auto-dismiss flash messages
document.querySelectorAll('.flash').forEach(flash => {
flash.classList.add('visible');
setTimeout(() => flash.classList.remove('visible'), 5000);
});
5. Preload Storefront Fonts
<%# In application layout <head> %>
<link rel="preload"
href="<%= asset_path('fonts/storefront.woff2') %>"
as="font" type="font/woff2" crossorigin>
@font-face {
font-family: 'StorefrontFont';
src: url('fonts/storefront.woff2') format('woff2');
font-display: swap;
size-adjust: 103%;
}
Measuring CLS on Spree
- Chrome DevTools Performance tab -- record product listing and detail page loads, filter for layout-shift entries
- Test variant selection -- click through product variants to check for image swap CLS
- Test cart actions -- add items and check for flash message CLS
- Mobile testing -- Spree's default responsive layout stacks product grid items differently on mobile
Analytics Script Impact
- Spree's built-in event system is JavaScript-based but injects no visible DOM elements
- E-commerce tracking (enhanced e-commerce for GA) should fire on Spree's JS events, not DOM observation
- Cookie consent banners should use
position: fixedoverlay - Avoid analytics tools that inject product recommendation widgets without reserved containers