Cumulative Layout Shift (CLS) measures visual stability by tracking unexpected layout shifts during page load. This guide provides Umbraco-specific solutions to eliminate layout shift in Razor views, Umbraco Forms, Block List Editor content, and dynamic components.
Understanding CLS in Umbraco
Common Causes in Umbraco Sites
- Media Picker images without dimensions - Umbraco images loaded without width/height
- Umbraco Forms - Forms rendering without reserved space
- Block List Editor - Dynamic blocks shifting during load
- Partial views - Async-loaded content without placeholders
- Grid layouts - Umbraco Grid content without proper sizing
- Dynamic navigation - Menus shifting when loaded
Target CLS Performance
- Good: CLS < 0.1
- Needs Improvement: CLS 0.1 - 0.25
- Poor: CLS > 0.25
Measure Current CLS
Using Chrome DevTools
- Open DevTools (F12)
- Click Lighthouse tab
- Select Performance
- Click Analyze page load
- Review Cumulative Layout Shift score
- Check View Trace to see shifting elements
Using Web Vitals Extension
- Install Web Vitals Chrome Extension
- Navigate to your Umbraco site
- Extension shows real-time CLS
- Click extension for detailed breakdown
Fix Umbraco Media Images
Set Image Dimensions from Umbraco
Get image dimensions in Razor:
@using Umbraco.Cms.Core.Models
@{
var heroImage = Model?.Value<IPublishedContent>("heroImage");
}
@if (heroImage != null)
{
var imageCropper = heroImage.Value<ImageCropperValue>("umbracoFile");
var width = imageCropper?.Width ?? 1200;
var height = imageCropper?.Height ?? 800;
<img src="@heroImage.Url()"
alt="@heroImage.Name"
width="@width"
height="@height"
style="aspect-ratio: @width / @height;" />
}
Use Image Cropper with Aspect Ratios
Define crops in Document Type:
- Navigate to Settings → Media Types → Image
- Edit Image Cropper property
- Add predefined crops:
- Hero: 16:9 (1920x1080)
- Thumbnail: 4:3 (800x600)
- Square: 1:1 (800x800)
In Razor:
@{
var heroImage = Model?.Value<IPublishedContent>("heroImage");
}
@if (heroImage != null)
{
var cropUrl = heroImage.GetCropUrl("umbracoFile", "hero");
var crop = heroImage.Value<ImageCropperValue>("umbracoFile")
?.Crops?.FirstOrDefault(c => c.Alias == "hero");
var width = crop?.Width ?? 1920;
var height = crop?.Height ?? 1080;
<img src="@cropUrl"
alt="@heroImage.Name"
width="@width"
height="@height"
style="aspect-ratio: @width / @height;" />
}
Create Image Helper Extension
ImageExtensions.cs:
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Models;
namespace YourProject.Extensions
{
public static class ImageExtensions
{
public static (int width, int height, string url) GetImageData(
this IPublishedContent image,
string cropAlias = null)
{
if (image == null)
return (0, 0, string.Empty);
var cropperValue = image.Value<ImageCropperValue>("umbracoFile");
if (cropperValue == null)
return (0, 0, string.Empty);
int width, height;
string url;
if (!string.IsNullOrEmpty(cropAlias))
{
var crop = cropperValue.Crops?
.FirstOrDefault(c => c.Alias == cropAlias);
width = crop?.Width ?? cropperValue.Width ?? 0;
height = crop?.Height ?? cropperValue.Height ?? 0;
url = image.GetCropUrl("umbracoFile", cropAlias);
}
else
{
width = cropperValue.Width ?? 0;
height = cropperValue.Height ?? 0;
url = image.Url();
}
return (width, height, url);
}
}
}
In Razor:
@using YourProject.Extensions
@{
var heroImage = Model?.Value<IPublishedContent>("heroImage");
var (width, height, url) = heroImage.GetImageData("hero");
}
@if (!string.IsNullOrEmpty(url))
{
<img src="@url"
alt="@heroImage.Name"
width="@width"
height="@height"
style="aspect-ratio: @width / @height;" />
}
Fix Umbraco Forms Layout Shift
Reserve Space for Forms
Before form loads:
@using Umbraco.Forms.Web
@model Umbraco.Forms.Web.Models.FormViewModel
<div class="form-container" style="min-height: 500px;">
@await Html.RenderFormAsync(Model)
</div>
<style>
.form-container {
min-height: 500px; /* Reserve space */
transition: min-height 0.3s ease;
}
.umbraco-forms-form {
width: 100%;
}
.umbraco-forms-form input[type="text"],
.umbraco-forms-form input[type="email"],
.umbraco-forms-form textarea {
width: 100%;
box-sizing: border-box;
}
</style>
Pre-render Form Structure
Create form skeleton:
<div class="form-skeleton" id="form-skeleton">
<div class="skeleton-field"></div>
<div class="skeleton-field"></div>
<div class="skeleton-field"></div>
<div class="skeleton-button"></div>
</div>
<div id="actual-form" style="display: none;">
@await Html.RenderFormAsync(Model)
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('form-skeleton').style.display = 'none';
document.getElementById('actual-form').style.display = 'block';
});
</script>
<style>
.skeleton-field {
height: 60px;
background: #f0f0f0;
margin-bottom: 20px;
border-radius: 4px;
}
.skeleton-button {
height: 45px;
width: 150px;
background: #e0e0e0;
border-radius: 4px;
}
</style>
Fix Block List Editor Layout Shift
Set Container Heights
For Block List items:
@using Umbraco.Cms.Core.Models.Blocks
@{
var blockList = Model?.Value<BlockListModel>("contentBlocks");
}
@if (blockList != null)
{
<div class="block-list">
@foreach (var block in blockList)
{
var blockType = block.Content.ContentType.Alias;
<div class="block-item block-@blockType"
data-block-type="@blockType"
style="min-height: @GetMinHeight(blockType);">
@await Html.PartialAsync($"~/Views/Partials/Blocks/{blockType}.cshtml", block)
</div>
}
</div>
}
@functions {
string GetMinHeight(string blockType)
{
return blockType switch
{
"heroBlock" => "500px",
"textBlock" => "200px",
"imageBlock" => "400px",
"quoteBlock" => "150px",
_ => "100px"
};
}
}
Use CSS Grid with Fixed Rows
<style>
.block-list {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
.block-hero {
grid-row: span 3; /* Reserve 3 row units */
}
.block-text {
grid-row: span 1;
}
.block-image {
grid-row: span 2;
}
</style>
Fix Dynamic Navigation Shift
Pre-render Navigation Structure
NavigationViewComponent.cs:
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Web;
using Microsoft.AspNetCore.Mvc;
namespace YourProject.ViewComponents
{
public class NavigationViewComponent : ViewComponent
{
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
public NavigationViewComponent(IUmbracoContextAccessor umbracoContextAccessor)
{
_umbracoContextAccessor = umbracoContextAccessor;
}
public IViewComponentResult Invoke()
{
if (!_umbracoContextAccessor.TryGetUmbracoContext(out var context))
return Content(string.Empty);
var root = context.Content?.GetAtRoot().FirstOrDefault();
var navItems = root?.Children()
.Where(x => x.Value<bool>("showInNavigation"))
.ToList();
return View(navItems);
}
}
}
Navigation.cshtml (View Component):
@model List<IPublishedContent>
<nav class="main-navigation" style="height: 60px;">
<ul>
@if (Model != null)
{
@foreach (var item in Model)
{
<li style="display: inline-block; margin-right: 20px;">
<a href="@item.Url()">@item.Name</a>
</li>
}
}
</ul>
</nav>
<style>
.main-navigation {
height: 60px; /* Fixed height prevents shift */
line-height: 60px;
}
.main-navigation ul {
list-style: none;
margin: 0;
padding: 0;
}
</style>
Fix Font Loading Shift
Use font-display: swap
In CSS:
@font-face {
font-family: 'CustomFont';
src: url('/fonts/customfont.woff2') format('woff2');
font-display: swap; /* Prevents invisible text and layout shift */
font-weight: 400;
font-style: normal;
}
Preload Critical Fonts
In Master.cshtml:
<head>
<link rel="preload"
href="/fonts/customfont.woff2"
as="font"
type="font/woff2"
crossorigin />
<style>
@font-face {
font-family: 'CustomFont';
src: url('/fonts/customfont.woff2') format('woff2');
font-display: swap;
}
body {
font-family: 'CustomFont', Arial, sans-serif;
}
</style>
</head>
Use System Fonts
Eliminate font loading entirely:
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Helvetica Neue', Arial, sans-serif;
}
Fix Lazy-Loaded Content
Use Intersection Observer with Placeholders
Create lazy load helper:
<div class="lazy-container"
data-src="@Model?.Value("contentUrl")"
style="min-height: 400px; background: #f5f5f5;">
<!-- Placeholder content -->
<div class="loading-skeleton">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const lazyContainers = document.querySelectorAll('.lazy-container');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const container = entry.target;
const src = container.getAttribute('data-src');
// Load content
fetch(src)
.then(response => response.text())
.then(html => {
container.innerHTML = html;
});
observer.unobserve(container);
}
});
});
lazyContainers.forEach(container => observer.observe(container));
});
</script>
<style>
.loading-skeleton {
padding: 20px;
}
.skeleton-line {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-bottom: 15px;
border-radius: 4px;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
Fix Advertisement/Widget Slots
Reserve Ad Slot Space
<div class="ad-slot"
style="width: 300px; height: 250px; background: #f9f9f9; border: 1px solid #ddd;">
<!-- Ad will load here -->
<div id="ad-container"></div>
</div>
<script async src="https://ad-provider.com/ad.js"></script>
<style>
.ad-slot {
width: 300px;
height: 250px; /* Fixed dimensions prevent shift */
overflow: hidden;
position: relative;
}
#ad-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
Testing and Monitoring
Measure CLS in JavaScript
<script>
// Track CLS with Performance Observer
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);
console.log('CLS Entry:', entry);
console.log('Total CLS:', clsValue);
}
}
});
observer.observe({type: 'layout-shift', buffered: true});
// Send to analytics on page unload
window.addEventListener('beforeunload', function() {
if (typeof gtag !== 'undefined') {
gtag('event', 'web_vitals', {
event_category: 'Web Vitals',
value: Math.round(clsValue * 1000),
metric_name: 'CLS',
page_path: window.location.pathname
});
}
});
</script>
Debug Layout Shifts
@inject Microsoft.Extensions.Hosting.IHostEnvironment HostEnvironment
@if (HostEnvironment.IsDevelopment())
{
<script>
// Highlight shifting elements
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
entry.sources.forEach(source => {
if (source.node) {
source.node.style.outline = '3px solid red';
console.log('Layout shift detected:', source.node);
}
});
}
}
});
observer.observe({type: 'layout-shift', buffered: true});
</script>
}
Common Issues
Grid Layout Without Dimensions
Problem: Umbraco Grid items shifting Solution:
<div class="grid-layout" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;">
@foreach (var item in gridItems)
{
<div class="grid-item" style="min-height: 300px;">
@* Content *@
</div>
}
</div>
Dynamic Member Content
Problem: Content changes based on logged-in member Solution:
@using Umbraco.Cms.Core.Security
@inject IMemberManager MemberManager
@{
var member = await MemberManager.GetCurrentMemberAsync();
}
<!-- Reserve space for both states -->
<div class="member-area" style="min-height: 100px;">
@if (member != null)
{
<p>Welcome, @member.Name!</p>
}
else
{
<p>Please log in</p>
}
</div>
Async Partial Views
Problem: Partial views loading asynchronously Solution:
<!-- Reserve space first -->
<div id="partial-container" style="min-height: 200px;">
@await Html.PartialAsync("~/Views/Partials/ContentBlock.cshtml")
</div>
Next Steps
- LCP Optimization - Improve loading performance
- Tracking Issues - Debug tracking problems
- Umbraco Performance - General optimization