Cumulative Layout Shift (CLS) measures visual stability by tracking unexpected layout shifts. This guide provides Kentico-specific solutions to achieve a CLS score under 0.1.
What is CLS?
CLS measures: The sum of all unexpected layout shift scores during page load.
Good CLS scores:
- Good: < 0.1
- Needs Improvement: 0.1 - 0.25
- Poor: > 0.25
Common causes:
- Images without dimensions
- Ads, embeds, iframes without reserved space
- Dynamically injected content
- Web fonts causing FOIT/FOUT
- Actions waiting for network response
Diagnosing CLS Issues
Using PageSpeed Insights
- Visit https://pagespeed.web.dev/
- Enter your Kentico site URL
- Review Diagnostics section
- Look for "Avoid large layout shifts"
- Identify which elements cause shifts
Using Chrome DevTools
- Open DevTools (F12)
- Performance tab → Capture
- Enable Web Vitals in settings
- Look for red Layout Shift bars
- Click to see which elements shifted
Layout Shift Regions
Enable layout shift visualization:
// Add to console or page
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('Layout shift:', entry);
console.log('Elements affected:', entry.sources);
}
}
}).observe({type: 'layout-shift', buffered: true});
Kentico-Specific CLS Fixes
1. Fix Images from Media Library
The most common CLS issue - images loading without dimensions.
Always Set Width and Height
@using CMS.MediaLibrary
@{
var mediaFile = MediaFileInfoProvider.GetMediaFileInfo(imageGuid);
}
<img src="@MediaFileURLProvider.GetMediaFileUrl(mediaFile.FileGUID, mediaFile.FileName)"
width="@mediaFile.FileImageWidth"
height="@mediaFile.FileImageHeight"
alt="@mediaFile.FileDescription"
loading="lazy">
Create Helper for Responsive Images
@using CMS.MediaLibrary
@using CMS.Helpers
@helper RenderResponsiveImage(Guid imageGuid, string altText, string cssClass = "")
{
var mediaFile = MediaFileInfoProvider.GetMediaFileInfo(imageGuid);
if (mediaFile != null)
{
var aspectRatio = (double)mediaFile.FileImageHeight / mediaFile.FileImageWidth * 100;
<div class="responsive-image-wrapper @cssClass" style="padding-bottom: @(aspectRatio)%;">
<img src="@MediaFileURLProvider.GetMediaFileUrl(mediaFile.FileGUID, mediaFile.FileName)"
alt="@altText"
loading="lazy"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;">
</div>
}
}
@* Usage: *@
@RenderResponsiveImage(Model.HeroImageGuid, "Hero image", "hero-image")
Use Aspect Ratio CSS
@{
var mediaFile = MediaFileInfoProvider.GetMediaFileInfo(imageGuid);
var aspectRatio = $"{mediaFile.FileImageWidth} / {mediaFile.FileImageHeight}";
}
<img src="@MediaFileURLProvider.GetMediaFileUrl(mediaFile.FileGUID, mediaFile.FileName)"
style="aspect-ratio: @aspectRatio; width: 100%; height: auto;"
alt="@mediaFile.FileDescription">
2. Fix Background Images
Background images can cause layout shifts if container height isn't set.
Set Container Dimensions
@{
var heroImage = DocumentContext.CurrentDocument.GetValue("HeroImage");
var heroImageFile = MediaFileInfoProvider.GetMediaFileInfo((Guid)heroImage);
var aspectRatio = (double)heroImageFile.FileImageHeight / heroImageFile.FileImageWidth * 100;
}
<section class="hero" style="padding-bottom: @(aspectRatio)%; position: relative;">
<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background-image: url('@MediaFileURLProvider.GetMediaFileUrl(heroImageFile.FileGUID, heroImageFile.FileName)');
background-size: cover; background-position: center;">
<!-- Content -->
</div>
</section>
Or Use Fixed Heights
.hero-section {
height: 500px; /* Or use vh units */
background-image: url('...');
background-size: cover;
background-position: center;
}
@media (max-width: 768px) {
.hero-section {
height: 300px;
}
}
3. Fix Web Parts (Portal Engine)
Portal Engine web parts can cause shifts when loading dynamically.
Reserve Space for Web Parts
<!-- In web part zone -->
<cms:CMSWebPartZone ID="zoneMain" runat="server" style="min-height: 400px;">
<cms:CMSWebPart ID="productListing" runat="server" />
</cms:CMSWebPartZone>
Use Skeleton Screens
<div class="webpart-container">
<div class="skeleton" id="skeleton_<%= ClientID %>">
<!-- Skeleton placeholder -->
<div class="skeleton-title"></div>
<div class="skeleton-text"></div>
<div class="skeleton-text"></div>
</div>
<div class="webpart-content" style="display: none;" id="content_<%= ClientID %>">
<!-- Actual content -->
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Hide skeleton, show content when ready
document.getElementById('skeleton_<%= ClientID %>').style.display = 'none';
document.getElementById('content_<%= ClientID %>').style.display = 'block';
});
</script>
4. Fix Font Loading (FOUT/FOIT)
Web fonts can cause text to shift when they load.
Use font-display
@font-face {
font-family: 'CustomFont';
src: url('/fonts/CustomFont.woff2') format('woff2');
font-display: swap; /* or 'optional' */
}
Preload Critical Fonts
<head>
<link rel="preload"
href="~/Content/fonts/primary-font.woff2"
as="font"
type="font/woff2"
crossorigin>
<style>
@font-face {
font-family: 'PrimaryFont';
src: url('/Content/fonts/primary-font.woff2') format('woff2');
font-display: swap;
}
</style>
</head>
Match Fallback Font Metrics
body {
font-family: 'CustomFont', Arial, sans-serif;
/* Adjust fallback font to match custom font metrics */
font-size: 16px;
line-height: 1.5;
}
/* Use size-adjust to match fallback */
@font-face {
font-family: 'CustomFont-Fallback';
src: local('Arial');
size-adjust: 105%; /* Adjust to match custom font */
}
body {
font-family: 'CustomFont', 'CustomFont-Fallback', Arial, sans-serif;
}
5. Fix Dynamic Content Injection
Kentico widgets and dynamic content can cause shifts.
Reserve Space for Dynamic Content
<div class="dynamic-content-area" style="min-height: 300px;">
@* Dynamic content will load here *@
</div>
Use MVC Widget with Fixed Dimensions
@using Kentico.PageBuilder.Web.Mvc
<div class="widget-container" style="min-height: @Model.MinHeight">
@if (Model.IsLoaded)
{
<!-- Widget content -->
}
else
{
<!-- Placeholder with same dimensions -->
<div class="widget-placeholder" style="height: @Model.MinHeight">
Loading...
</div>
}
</div>
6. Fix Ads and Third-Party Embeds
Ads and embeds are common CLS culprits.
Reserve Space for Ads
<div class="ad-container" style="width: 728px; height: 90px; background: #f0f0f0;">
<div id="ad-slot-1">
<!-- Ad code -->
</div>
</div>
For Responsive Ads
<div class="ad-wrapper" style="padding-bottom: 12.36%; position: relative;">
<!-- 12.36% = 90/728 for 728x90 ad -->
<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
<!-- Ad code -->
</div>
</div>
YouTube Embeds
<!-- Reserve 16:9 aspect ratio -->
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
<iframe style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
src="https://www.youtube.com/embed/VIDEO_ID"
frameborder="0"
allowfullscreen>
</iframe>
</div>
7. Fix Kentico Page Builder
Page Builder widgets can cause layout shifts.
Set Widget Section Heights
@* In your section/widget view *@
<div class="widget-section" data-widget-section style="min-height: 400px;">
@RenderBody()
</div>
Create Stable Widget Containers
// In your widget model
public class WidgetViewModel
{
public int MinHeight { get; set; } = 300;
public string AspectRatio { get; set; } = "16/9";
}
@model WidgetViewModel
<div class="widget-wrapper" style="aspect-ratio: @Model.AspectRatio; min-height: @(Model.MinHeight)px;">
<!-- Widget content -->
</div>
8. Fix Navigation Menus
Menus that load or transform can cause shifts.
Reserve Space for Dropdowns
.nav-dropdown {
position: absolute; /* Don't push content down */
top: 100%;
left: 0;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
}
.nav-item:hover .nav-dropdown {
opacity: 1;
visibility: visible;
}
Prevent Mobile Menu Shift
/* Reserve space for mobile menu toggle */
.mobile-menu-toggle {
width: 44px; /* Fixed width */
height: 44px; /* Fixed height */
}
/* Overlay menu instead of pushing content */
.mobile-menu {
position: fixed;
top: 0;
left: -100%;
width: 80%;
height: 100vh;
transition: left 0.3s;
}
.mobile-menu.open {
left: 0;
}
9. Fix Form Validation Messages
Error messages that appear can cause shifts.
Reserve Space for Error Messages
<div class="form-group">
@Html.LabelFor(m => m.Email)
@Html.TextBoxFor(m => m.Email, new { @class = "form-control" })
<div class="error-message" style="min-height: 20px;">
@Html.ValidationMessageFor(m => m.Email, "", new { @class = "text-danger" })
</div>
</div>
Or Use Absolute Positioning
.form-group {
position: relative;
margin-bottom: 30px; /* Space for error message */
}
.error-message {
position: absolute;
bottom: -25px;
left: 0;
color: red;
font-size: 14px;
}
10. Fix Kentico Macros
Macros that resolve slowly can cause shifts.
Cache Macro Results
@{
// Cache expensive macro results
var macroResult = CacheHelper.Cache(cs =>
{
var result = MacroResolver.Resolve("{% CurrentDocument.DocumentName %}");
if (cs.Cached)
{
cs.CacheDependency = CacheHelper.GetCacheDependency("node|" + DocumentContext.CurrentDocument.NodeID);
}
return result;
}, new CacheSettings(60, "macroResult", DocumentContext.CurrentDocument.NodeID));
}
<div style="min-height: 50px;">
@Html.Raw(macroResult)
</div>
CSS Techniques for Preventing CLS
1. Aspect Ratio Boxes
.aspect-ratio-box {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 ratio */
}
.aspect-ratio-box > * {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
2. Min-Height for Dynamic Content
.dynamic-content {
min-height: 300px;
}
@media (max-width: 768px) {
.dynamic-content {
min-height: 200px;
}
}
3. Transform Instead of Position
/* BAD: Changes position, causes shift */
.dropdown {
display: none;
}
.dropdown.active {
display: block;
}
/* GOOD: Uses transform, no shift */
.dropdown {
transform: translateY(-100%);
opacity: 0;
visibility: hidden;
transition: transform 0.2s, opacity 0.2s;
}
.dropdown.active {
transform: translateY(0);
opacity: 1;
visibility: visible;
}
JavaScript Best Practices
1. Set Dimensions Before Loading
// Get image from Kentico Media Library
fetch('/api/media/info?guid=' + imageGuid)
.then(response => response.json())
.then(data => {
var img = document.createElement('img');
img.width = data.width; // Set dimensions first
img.height = data.height;
img.src = data.url; // Then load image
container.appendChild(img);
});
2. Batch DOM Changes
// BAD: Multiple reflows
container.style.height = '200px';
container.style.width = '300px';
container.classList.add('active');
// GOOD: Single reflow
container.style.cssText = 'height: 200px; width: 300px;';
container.classList.add('active');
3. Use RequestAnimationFrame
function updateLayout() {
requestAnimationFrame(() => {
// DOM changes here
element.style.height = newHeight + 'px';
});
}
Testing CLS Fixes
1. Web Vitals JavaScript Library
<script type="module">
import {getCLS} from 'https://unpkg.com/web-vitals@3/dist/web-vitals.js';
getCLS(({name, value, rating}) => {
console.log('CLS:', value, rating);
// Send to analytics
gtag('event', 'web_vitals', {
'metric_name': name,
'metric_value': value,
'metric_rating': rating
});
});
</script>
2. Layout Shift Recorder
<script>
let clsScore = 0;
const clsObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsScore += entry.value;
console.log('Layout Shift:', entry.value);
console.log('Total CLS:', clsScore);
console.log('Shifted elements:', entry.sources);
}
}
});
clsObserver.observe({type: 'layout-shift', buffered: true});
// Report final CLS
window.addEventListener('beforeunload', () => {
console.log('Final CLS Score:', clsScore);
});
</script>
3. PageSpeed Insights API
// Automate testing
const url = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed';
const params = new URLSearchParams({
url: 'https://yoursite.com',
key: 'YOUR_API_KEY'
});
fetch(`${url}?${params}`)
.then(response => response.json())
.then(data => {
const cls = data.lighthouseResult.audits['cumulative-layout-shift'].numericValue;
console.log('CLS Score:', cls);
});
Common CLS Issues and Solutions
Issue: Images Loading Without Dimensions
Solution:
Issue: Web Fonts Causing Shift
Solution:
- Use
font-display: swaporoptional - Preload critical fonts
- Match fallback font metrics
Issue: Ads Causing Shift
Solution:
- Reserve fixed space for ad slots
- Use padding-bottom technique
- Consider lazy loading ads
Issue: Dynamic Content Insertion
Solution:
- Reserve min-height for containers
- Use skeleton screens
- Batch DOM updates
Issue: Portal Engine ViewState
Solution:
<appSettings>
<add key="CMSControlState" value="false" />
</appSettings>
Monitoring CLS
Google Search Console
- Go to Core Web Vitals report
- Check CLS metrics
- Identify problematic URLs
- Fix and request re-crawl
Real User Monitoring
<script type="module">
import {getCLS} from 'https://unpkg.com/web-vitals@3/dist/web-vitals.js';
getCLS(({value}) => {
// Send to your analytics
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({
metric: 'CLS',
value: value,
url: window.location.href
})
});
});
</script>
Best Practices
- Always Set Image Dimensions: Width and height attributes
- Reserve Space: For dynamic content, ads, embeds
- Optimize Fonts: Use font-display and preload
- Avoid Injection: Don't insert content above existing content
- Use Transforms: Instead of changing position/dimensions
- Test on Real Devices: Mobile devices especially prone to CLS
- Monitor Continuously: Track CLS metrics over time
- Fix Highest Impact: Focus on most-visited pages first