Eliminate Cumulative Layout Shift (CLS) on your NopCommerce store to improve Core Web Vitals, enhance user experience, and prevent frustrating page jumps during load.
Understanding CLS in NopCommerce
What is CLS?
Cumulative Layout Shift (CLS) measures the visual stability of your page. It quantifies how much unexpected layout movement occurs during the entire lifespan of the page.
Common causes in NopCommerce:
- Images without dimensions
- Web fonts loading/swapping
- Dynamic content injection (cart updates, banners)
- Ads or embeds without reserved space
- AJAX-loaded content
CLS Scoring Thresholds
| Score | Range | User Experience |
|---|---|---|
| Good | ≤ 0.1 | Stable, excellent |
| Needs Improvement | 0.1 - 0.25 | Some shifting |
| Poor | > 0.25 | Unstable, frustrating |
Measuring CLS
// Measure CLS in browser console
new PerformanceObserver((list) => {
let cls = 0;
list.getEntries().forEach((entry) => {
if (!entry.hadRecentInput) {
cls += entry.value;
console.log('Layout Shift:', entry.value);
console.log('Affected Element:', entry.sources);
}
});
console.log('Total CLS:', cls);
}).observe({entryTypes: ['layout-shift']});
Common NopCommerce CLS Issues
1. Images Without Dimensions
Problem: Product images load without width/height, causing shifts.
Default NopCommerce Behavior:
@* Typical NopCommerce image output *@
<img src="@Model.DefaultPictureModel.ImageUrl" alt="@Model.DefaultPictureModel.AlternateText" />
Solution: Add Explicit Dimensions
Modify /Themes/YourTheme/Views/Catalog/_ProductBox.cshtml:
@model ProductOverviewModel
<div class="product-item">
<div class="picture">
<a href="@Url.RouteUrl("Product", new { SeName = Model.SeName })">
<img src="@Model.DefaultPictureModel.ImageUrl"
alt="@Model.DefaultPictureModel.AlternateText"
title="@Model.DefaultPictureModel.Title"
width="@Model.DefaultPictureModel.ThumbImageWidth"
height="@Model.DefaultPictureModel.ThumbImageHeight"
loading="lazy" />
</a>
</div>
</div>
CSS Aspect Ratio Support:
/* themes/yourtheme/content/css/styles.css */
.product-item .picture img {
aspect-ratio: 1 / 1; /* For square product images */
width: 100%;
height: auto;
object-fit: cover;
}
.category-grid img {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
}
2. Product Image Gallery
Problem: Gallery images shift when thumbnails load.
Solution: Reserve Space for Gallery
@* Product details image gallery *@
<div class="gallery-wrapper">
<div class="main-image" style="aspect-ratio: 1 / 1;">
<img src="@Model.DefaultPictureModel.FullSizeImageUrl"
alt="@Model.DefaultPictureModel.AlternateText"
width="550"
height="550" />
</div>
<div class="thumbnail-list">
@foreach (var picture in Model.PictureModels)
{
<div class="thumbnail-item" style="width: 80px; height: 80px;">
<img src="@picture.ImageUrl"
alt="@picture.AlternateText"
width="80"
height="80" />
</div>
}
</div>
</div>
CSS for Gallery:
.gallery-wrapper {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
}
.main-image {
position: relative;
overflow: hidden;
background: #f5f5f5; /* Placeholder color */
}
.main-image img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-list {
display: flex;
gap: 10px;
}
.thumbnail-item {
flex-shrink: 0;
background: #f5f5f5;
position: relative;
overflow: hidden;
}
.thumbnail-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
3. Web Font Loading
Problem: Fonts loading causes text to shift (FOIT/FOUT).
Default Font Loading:
@* Typical font loading *@
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
Solution A: Preload Critical Fonts
@* In _Root.Head.cshtml *@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" media="print"
Solution B: Self-Host Fonts
/* CSS with self-hosted fonts */
@font-face {
font-family: 'Roboto';
src: url('../fonts/roboto-v30-latin-regular.woff2') format('woff2'),
url('../fonts/roboto-v30-latin-regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap; /* Prevents invisible text */
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/roboto-v30-latin-700.woff2') format('woff2'),
url('../fonts/roboto-v30-latin-700.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
}
Solution C: Use font-display Property
/* Ensure font-display is set */
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
body {
font-family: 'Roboto', Arial, sans-serif; /* Fallback font */
}
4. Shopping Cart Updates
Problem: Cart widget updates cause header to shift.
Solution: Reserve Space for Cart
@* Themes/YourTheme/Views/Shared/Components/ShoppingCartLink/Default.cshtml *@
<div class="header-links-wrapper">
<div class="shopping-cart-link" style="min-width: 120px;">
<a href="@Url.RouteUrl("ShoppingCart")">
<span class="cart-label">Shopping cart</span>
<span class="cart-qty">(@Model.ShoppingCartItems)</span>
</a>
</div>
</div>
CSS for Cart Link:
.shopping-cart-link {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 120px; /* Prevent width changes */
justify-content: flex-end;
}
.cart-qty {
display: inline-block;
min-width: 25px;
text-align: center;
}
5. Dynamic Content/Banners
Problem: Banner content loading causes layout shifts.
Solution: Skeleton Loaders
@* Banner with skeleton loader *@
<div class="banner-wrapper">
@if (Model.BannerLoaded)
{
<img src="@Model.BannerUrl" alt="@Model.BannerAlt" width="1200" height="300" />
}
else
{
<div class="skeleton-banner" style="width: 1200px; height: 300px; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: loading 1.5s infinite;"></div>
}
</div>
CSS Animation:
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-banner {
border-radius: 4px;
}
6. AJAX Product Listings
Problem: Product grid shifts when AJAX loads more items.
Solution: Fixed Grid Heights
@model CatalogProductsModel
<div class="product-grid" style="min-height: 1000px;">
@foreach (var product in Model.Products)
{
<div class="product-item" style="height: 400px;">
@await Html.PartialAsync("_ProductBox", product)
</div>
}
</div>
CSS Grid with Fixed Rows:
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-auto-rows: 400px; /* Fixed row height */
gap: 20px;
}
.product-item {
display: flex;
flex-direction: column;
}
.product-item .picture {
aspect-ratio: 1 / 1;
overflow: hidden;
background: #f5f5f5;
}
.product-item .details {
flex: 1;
display: flex;
flex-direction: column;
}
7. Flyout Menus/Dropdowns
Problem: Mega menus cause shifts when appearing.
Solution: Absolute Positioning
.header-menu {
position: relative;
}
.mega-menu {
position: absolute;
top: 100%;
left: 0;
width: 100%;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: opacity 0.3s, transform 0.3s, visibility 0.3s;
}
.header-menu:hover .mega-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
/* Prevent layout shift by not affecting document flow */
.mega-menu {
z-index: 1000;
}
8. Notification Bars
Problem: Cookie consent or notification bars push content down.
Solution: Fixed Positioning
@* Cookie consent bar *@
<div class="cookie-consent-bar" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 9999;">
<div class="consent-content">
<p>We use cookies to improve your experience...</p>
<button type="button" class="accept-button">Accept</button>
</div>
</div>
CSS:
.cookie-consent-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #333;
color: #fff;
padding: 15px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
transform: translateY(100%);
transition: transform 0.3s;
}
.cookie-consent-bar.show {
transform: translateY(0);
}
C# Server-Side Solutions
Set Image Dimensions in Model
// Custom view model or service
public class ProductPictureModel
{
public string ImageUrl { get; set; }
public string AlternateText { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public string AspectRatio => $"{Width} / {Height}";
}
// In product service or controller
public async Task<ProductPictureModel> GetProductPictureAsync(int productId)
{
var picture = await _pictureService.GetPictureByIdAsync(productId);
var (width, height) = await _pictureService.GetPictureSizeAsync(picture);
return new ProductPictureModel
{
ImageUrl = picture.Url,
AlternateText = picture.AltText,
Width = width,
Height = height
};
}
Precompute Layout Dimensions
// Custom plugin or service
public class LayoutOptimizationService
{
private readonly ISettingService _settingService;
public async Task<Dictionary<string, (int Width, int Height)>> GetImageDimensionsAsync()
{
var mediaSettings = await _settingService.LoadSettingAsync<MediaSettings>();
return new Dictionary<string, (int, int)>
{
["ProductThumb"] = (mediaSettings.ProductThumbPictureSize, mediaSettings.ProductThumbPictureSize),
["ProductDetail"] = (mediaSettings.ProductDetailsPictureSize, mediaSettings.ProductDetailsPictureSize),
["CategoryThumb"] = (mediaSettings.CategoryThumbPictureSize, mediaSettings.CategoryThumbPictureSize)
};
}
}
Testing for CLS
Chrome DevTools
- Open DevTools (F12)
- Go to Performance tab
- Enable Web Vitals in settings
- Record page load
- Look for red "Layout Shift" markers
- Click to see affected elements
Lighthouse
DevTools > Lighthouse > Generate Report
Check:
- Cumulative Layout Shift score
- Diagnostics: "Avoid large layout shifts"
- Elements causing shifts
Layout Shift Debugger
// Add to page temporarily
<script>
new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (!entry.hadRecentInput && entry.value > 0.001) {
console.warn('Layout Shift detected:', entry.value);
entry.sources.forEach((source) => {
console.log('Element:', source.node);
// Highlight element
if (source.node) {
source.node.style.outline = '3px solid red';
setTimeout(() => {
source.node.style.outline = '';
}, 3000);
}
});
}
});
}).observe({entryTypes: ['layout-shift']});
</script>
Prevention Checklist
Implement these to prevent CLS:
- Add width and height attributes to all images
- Use aspect-ratio CSS for responsive images
- Preload critical web fonts with font-display: swap
- Reserve space for ads/embeds
- Use transforms for animations (not top/left)
- Avoid inserting content above existing content
- Use fixed dimensions for dynamic content areas
- Position overlays/modals with position: fixed
- Load skeleton screens for async content
- Test with throttled network in DevTools
NopCommerce Plugin for CLS Optimization
// Custom plugin to inject dimensions
public class CLSOptimizationPlugin : BasePlugin, IWidgetPlugin
{
public async Task<string> GetWidgetViewComponentNameAsync(string widgetZone)
{
return await Task.FromResult("CLSOptimization");
}
public Task<IList<string>> GetWidgetZonesAsync()
{
return Task.FromResult<IList<string>>(new List<string>
{
PublicWidgetZones.HeadHtmlTag
});
}
}
// View component
public class CLSOptimizationViewComponent : NopViewComponent
{
public IViewComponentResult Invoke()
{
return View("~/Plugins/Widgets.CLSOptimization/Views/PublicInfo.cshtml");
}
}
View Template:
<style>
/* Prevent CLS globally */
img:not([width]):not([height]) {
aspect-ratio: attr(width) / attr(height);
}
/* Reserve space for common elements */
.product-grid {
grid-auto-rows: 400px;
}
.header-links {
min-height: 50px;
}
</style>
Monitoring CLS in Production
Real User Monitoring
<script>
// Send CLS to analytics
new PerformanceObserver((list) => {
let cls = 0;
list.getEntries().forEach((entry) => {
if (!entry.hadRecentInput) {
cls += entry.value;
}
});
// Send to Google Analytics
if (typeof gtag !== 'undefined') {
gtag('event', 'web_vitals', {
'event_category': 'Web Vitals',
'event_label': 'CLS',
'value': Math.round(cls * 1000)
});
}
}).observe({entryTypes: ['layout-shift']});
</script>
Next Steps
- LCP Optimization - Improve loading speed
- Events Not Firing - Debug tracking
- GTM Setup - Tag management