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

Fix CLS Issues on Spreecommerce (Layout Shift)

Stabilize Spree Commerce layouts by sizing product variant images, preloading storefront fonts, and reserving Deface override containers.

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 without width/height attributes
  • 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

  1. Chrome DevTools Performance tab -- record product listing and detail page loads, filter for layout-shift entries
  2. Test variant selection -- click through product variants to check for image swap CLS
  3. Test cart actions -- add items and check for flash message CLS
  4. 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: fixed overlay
  • Avoid analytics tools that inject product recommendation widgets without reserved containers