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

Fix CLS Issues on OpenCart (Layout Shift)

Stabilize OpenCart layouts by sizing product images in Twig templates, preloading storefront fonts, and reserving extension module space.

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

  1. Open your store in Chrome
  2. Press F12 to open DevTools
  3. Click Performance tab
  4. Check Web Vitals checkbox
  5. Click Record and interact with page
  6. 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;
}

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">&times;</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: swap to 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

  1. Forgetting image dimensions - Always set width and height
  2. Dynamic height changes - Use min-height or fixed heights
  3. AJAX without placeholders - Reserve space before loading
  4. Absolute positioning without container - Use relative containers
  5. Not testing on mobile - CLS often worse on mobile

Next Steps

Additional Resources