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

Fix CLS Issues on Umbraco (Layout Shift)

Stabilize Umbraco layouts by sizing Media Picker images in Razor views, preloading theme fonts, and reserving Block List Editor containers.

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

  1. Open DevTools (F12)
  2. Click Lighthouse tab
  3. Select Performance
  4. Click Analyze page load
  5. Review Cumulative Layout Shift score
  6. Check View Trace to see shifting elements

Using Web Vitals Extension

  1. Install Web Vitals Chrome Extension
  2. Navigate to your Umbraco site
  3. Extension shows real-time CLS
  4. 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:

  1. Navigate to Settings → Media Types → Image
  2. Edit Image Cropper property
  3. 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