Overview
Cumulative Layout Shift (CLS) measures visual stability by tracking unexpected layout shifts during page load. High CLS frustrates users when content moves as they try to interact with it.
Target Scores:
- Good: Under 0.1
- Needs Improvement: 0.1 - 0.25
- Poor: Over 0.25
Common CLS causes in OpenCart:
- Images without dimensions
- Dynamic content injections
- Web fonts causing text reflow
- Ads and embeds loading
- Cookie consent banners
Measuring CLS
Using Chrome DevTools
- Open your store in Chrome
- Press F12 to open DevTools
- Click Performance tab
- Check Web Vitals checkbox
- Click Record and interact with page
- Click Stop and review Experience section
Using Web Vitals Library
File: catalog/view/theme/[your-theme]/template/common/header.twig
<head>
{# ... existing code ... #}
<!-- Web Vitals Monitoring -->
<script type="module">
import {getCLS} from 'https://unpkg.com/web-vitals@3/dist/web-vitals.js';
getCLS((metric) => {
console.log('CLS:', metric.value);
console.log('CLS Rating:', metric.rating);
console.log('Shifts:', metric.entries);
// Send to Google Analytics
if (typeof gtag !== 'undefined') {
gtag('event', 'web_vitals', {
'metric_name': 'CLS',
'metric_value': metric.value,
'metric_rating': metric.rating,
'page_path': window.location.pathname
});
}
}, {reportAllChanges: true});
</script>
</head>
Visual Debugging
<script>
// Highlight elements causing layout shift
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
cls += entry.value;
console.log('Layout shift:', entry);
console.log('Shifted elements:', entry.sources);
// Highlight shifted elements
entry.sources.forEach((source) => {
if (source.node) {
source.node.style.outline = '3px solid red';
}
});
}
}
console.log('Current CLS:', cls);
}).observe({type: 'layout-shift', buffered: true});
</script>
Common CLS Issues in OpenCart
1. Images Without Dimensions
Problem: Images load and cause content to shift down
Bad Example:
<img src="{{ product.thumb }}" alt="{{ product.name }}" class="img-responsive">
Solutions:
A. Add Width and Height Attributes
File: catalog/view/theme/[your-theme]/template/product/category.twig
{% for product in products %}
<div class="product-thumb">
<div class="image">
<a href="{{ product.href }}">
<img src="{{ product.thumb }}"
alt="{{ product.name }}"
width="228"
height="228"
class="img-responsive" />
</a>
</div>
</div>
{% endfor %}
B. Use Aspect Ratio Boxes
File: catalog/view/theme/[your-theme]/stylesheet/stylesheet.css
/* Aspect ratio container for product images */
.product-thumb .image {
position: relative;
width: 100%;
padding-bottom: 100%; /* 1:1 aspect ratio */
overflow: hidden;
}
.product-thumb .image img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
C. Modern aspect-ratio CSS
.product-thumb .image img {
aspect-ratio: 1 / 1;
width: 100%;
height: auto;
}
D. Controller-Side Dimensions
File: catalog/controller/product/category.php
Pass image dimensions to template:
foreach ($results as $result) {
// ... existing product data ...
$image = $this->model_tool_image->resize($result['image'], $this->config->get('theme_' . $this->config->get('config_theme') . '_image_product_width'), $this->config->get('theme_' . $this->config->get('config_theme') . '_image_product_height'));
$data['products'][] = array(
// ... existing fields ...
'thumb' => $image,
'thumb_width' => $this->config->get('theme_' . $this->config->get('config_theme') . '_image_product_width'),
'thumb_height' => $this->config->get('theme_' . $this->config->get('config_theme') . '_image_product_height'),
);
}
Template:
<img src="{{ product.thumb }}"
alt="{{ product.name }}"
width="{{ product.thumb_width }}"
height="{{ product.thumb_height }}"
class="img-responsive">
2. Dynamic Content Injection
Problem: Cart totals, product prices, or notifications loading and shifting content
Solutions:
A. Reserve Space for Cart Module
File: catalog/view/theme/[your-theme]/stylesheet/stylesheet.css
/* Reserve minimum height for cart dropdown */
#cart {
min-height: 40px;
}
#cart > button {
min-width: 120px; /* Prevent width changes when count updates */
}
#cart .dropdown-menu {
min-height: 100px; /* Reserve space even when empty */
}
B. Skeleton Loaders for AJAX Content
File: catalog/view/theme/[your-theme]/template/common/cart.twig
<div id="cart" class="btn-group btn-block">
<button type="button" class="btn btn-inverse btn-block btn-lg dropdown-toggle">
<span id="cart-total">
<!-- Skeleton loader while cart loads -->
<span class="skeleton-loader" style="display: inline-block; width: 80px; height: 16px; background: #f0f0f0;"></span>
</span>
</button>
<ul class="dropdown-menu pull-right">
<!-- Content loads here -->
</ul>
</div>
<script>
$(document).ready(function() {
// Load cart content
$('#cart').load('index.php?route=common/cart/info', function() {
// Remove skeleton loader after content loads
$('.skeleton-loader').remove();
});
});
</script>
C. Fixed Height for Price Areas
File: catalog/view/theme/[your-theme]/stylesheet/stylesheet.css
.product-thumb .price {
min-height: 24px; /* Prevent height collapse */
display: block;
}
.price-new,
.price-old {
display: inline-block;
min-width: 60px; /* Prevent width changes */
}
3. Web Font Loading (FOIT/FOUT)
Problem: Text reflows when web fonts load
Solutions:
A. Use font-display: swap
File: catalog/view/theme/[your-theme]/stylesheet/fonts.css
@font-face {
font-family: 'Open Sans';
src: url('../fonts/OpenSans-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap; /* Prevent invisible text, allow reflow */
}
@font-face {
font-family: 'Open Sans';
src: url('../fonts/OpenSans-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
B. Preload Critical Fonts
File: catalog/view/theme/[your-theme]/template/common/header.twig
<head>
<!-- Preload critical fonts to load earlier -->
<link rel="preload" href="catalog/view/theme/default/fonts/OpenSans-Regular.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="catalog/view/theme/default/fonts/OpenSans-Bold.woff2" as="font" type="font/woff2" crossorigin>
<!-- Font CSS -->
<link rel="stylesheet" href="catalog/view/theme/default/stylesheet/fonts.css">
</head>
C. Use System Font Stack (No CLS)
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
4. Cookie Consent Banner
Problem: Banner appears after page load, shifting content
Solutions:
A. Reserve Space for Banner
File: catalog/view/theme/[your-theme]/stylesheet/stylesheet.css
body {
padding-bottom: 80px; /* Reserve space for cookie banner */
}
body.cookies-accepted {
padding-bottom: 0; /* Remove padding after accepted */
}
.cookie-consent {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 80px;
background: #333;
color: #fff;
z-index: 9999;
}
B. Use CSS-Only Initial State
<style>
/* Show banner space on load, hide after JS confirms no consent */
.cookie-consent-placeholder {
height: 80px;
background: #f5f5f5;
}
</style>
<div class="cookie-consent-placeholder" id="cookie-placeholder"></div>
<script>
// Check if cookie consent already given
if (document.cookie.indexOf('cookie_consent=1') !== -1) {
// Hide placeholder immediately
document.getElementById('cookie-placeholder').style.display = 'none';
} else {
// Show actual banner, remove placeholder
setTimeout(function() {
document.getElementById('cookie-placeholder').style.display = 'none';
document.getElementById('cookie-banner').style.display = 'block';
}, 100);
}
</script>
5. Notification Messages
Problem: Success/error messages appear and shift content down
Solutions:
A. Fixed Position Notifications
File: catalog/view/theme/[your-theme]/stylesheet/stylesheet.css
.alert {
position: fixed;
top: 60px; /* Below header */
left: 50%;
transform: translateX(-50%);
z-index: 9999;
min-width: 300px;
max-width: 600px;
margin: 0 auto;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
transform: translate(-50%, -100%);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
File: catalog/view/theme/[your-theme]/template/common/success.twig
{% if success %}
<div class="alert alert-success alert-dismissible" role="alert" style="position: fixed;">
<button type="button" class="close" data-dismiss="alert">×</button>
{{ success }}
</div>
{% endif %}
6. Product Options Dropdown
Problem: Options loading causes product form to shift
Solutions:
A. Reserve Minimum Height
File: catalog/view/theme/[your-theme]/stylesheet/stylesheet.css
#product .form-group {
min-height: 60px; /* Reserve space for option */
}
#product .form-group select,
#product .form-group input {
min-height: 34px;
}
B. Load Options Server-Side
Avoid loading options via AJAX. Include in initial page render:
File: catalog/controller/product/product.php
// Load all options on page load (not via AJAX)
$data['options'] = array();
foreach ($this->model_catalog_product->getProductOptions($product_id) as $option) {
// ... build option data ...
$data['options'][] = $option_data;
}
7. Lazy-Loaded Images
Problem: Images lazy load and cause layout shifts
Solutions:
A. Always Set Dimensions
<img src="placeholder.jpg"
data-src="{{ product.thumb }}"
alt="{{ product.name }}"
width="228"
height="228"
loading="lazy"
class="lazy img-responsive">
B. Use Aspect Ratio Container
.lazy-image-container {
aspect-ratio: 1 / 1;
background: #f5f5f5;
position: relative;
}
.lazy-image-container img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
8. Header Elements
Problem: Cart count, currency selector, or search form causing header shifts
Solutions:
A. Fixed Header Heights
File: catalog/view/theme/[your-theme]/stylesheet/stylesheet.css
#header {
min-height: 80px;
}
#header .container {
display: flex;
align-items: center;
min-height: 80px;
}
#logo {
width: 200px; /* Fixed width */
height: 60px; /* Fixed height */
}
#search {
flex: 1;
min-width: 200px;
}
#cart {
width: 200px; /* Fixed width prevents shifting */
}
B. CSS Grid for Stable Layout
#header .container {
display: grid;
grid-template-columns: 200px 1fr 200px;
grid-gap: 15px;
align-items: center;
min-height: 80px;
}
#logo { grid-column: 1; }
#search { grid-column: 2; }
#cart { grid-column: 3; }
9. Slideshow/Carousel
Problem: Slider initializes and causes layout shift
Solutions:
A. Reserve Exact Height
File: catalog/view/theme/[your-theme]/stylesheet/stylesheet.css
.slideshow-container {
height: 500px; /* Fixed height matching slider images */
overflow: hidden;
background: #f5f5f5;
}
.slideshow-container .owl-carousel {
height: 100%;
}
.slideshow-container .owl-carousel .item {
height: 500px;
}
.slideshow-container img {
width: 100%;
height: 500px;
object-fit: cover;
}
B. Aspect Ratio for Responsive
.slideshow-container {
aspect-ratio: 16 / 9;
width: 100%;
position: relative;
background: #f5f5f5;
}
.slideshow-container img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
10. Third-Party Embeds
Problem: YouTube videos, social media widgets causing shifts
Solutions:
A. Aspect Ratio Container for Videos
File: catalog/view/theme/[your-theme]/stylesheet/stylesheet.css
.video-container {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
height: 0;
overflow: hidden;
}
.video-container iframe,
.video-container object,
.video-container embed {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
Usage:
<div class="video-container">
<iframe src="https://www.youtube.com/embed/VIDEO_ID" frameborder="0" allowfullscreen></iframe>
</div>
Testing CLS Fixes
Real-User Monitoring
File: catalog/view/theme/[your-theme]/template/common/header.twig
<script>
// Track CLS in production
let clsValue = 0;
let clsEntries = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
clsEntries.push(entry);
}
}
});
observer.observe({type: 'layout-shift', buffered: true});
// Send to analytics on page unload
window.addEventListener('beforeunload', () => {
if (clsValue > 0 && typeof gtag !== 'undefined') {
gtag('event', 'cls_final', {
'value': Math.round(clsValue * 1000),
'page_path': window.location.pathname,
'rating': clsValue < 0.1 ? 'good' : clsValue < 0.25 ? 'needs_improvement' : 'poor',
'shifts_count': clsEntries.length
});
}
});
</script>
Lighthouse Testing
# Test with Lighthouse CLI
npm install -g lighthouse
# Run test
lighthouse https://your-store.com/ \
--only-categories=performance \
--view
Visual Comparison
Before/After Screenshots:
# Install Puppeteer
npm install puppeteer
# Create screenshot script
node screenshot-compare.js
File: screenshot-compare.js
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://your-store.com/', {
waitUntil: 'networkidle2'
});
// Take screenshots at intervals
await page.screenshot({path: 'screenshot-0ms.png'});
await page.waitForTimeout(500);
await page.screenshot({path: 'screenshot-500ms.png'});
await page.waitForTimeout(500);
await page.screenshot({path: 'screenshot-1000ms.png'});
await browser.close();
})();
Quick Wins Checklist
- Add width/height to all images
- Use aspect-ratio CSS for image containers
- Add
font-display: swapto web fonts - Reserve space for dynamic content (cart, notifications)
- Set fixed heights for header and footer
- Use CSS Grid/Flexbox for stable layouts
- Reserve space for cookie consent banner
- Add min-height to AJAX-loaded sections
- Preload critical fonts
- Use fixed positioning for notifications
Expected Improvements
After implementing CLS fixes:
- CLS score: Typically improves from 0.25+ to under 0.1
- User experience: Fewer accidental clicks
- Conversion rate: +5-10% improvement
- Bounce rate: 5-15% decrease
Common Mistakes to Avoid
- Forgetting image dimensions - Always set width and height
- Dynamic height changes - Use min-height or fixed heights
- AJAX without placeholders - Reserve space before loading
- Absolute positioning without container - Use relative containers
- Not testing on mobile - CLS often worse on mobile