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

Fix CLS Issues on Typo3 (Layout Shift)

Stabilize TYPO3 layouts by sizing FAL image renderings, preloading TypoScript-loaded fonts, and reserving Fluid template content containers.

Learn how to eliminate Cumulative Layout Shift (CLS) in TYPO3 sites through proper image sizing, font optimization, and careful handling of dynamic content in Fluid templates and TypoScript.

Understanding CLS in TYPO3

Cumulative Layout Shift measures visual stability by tracking unexpected layout shifts. In TYPO3, CLS issues commonly occur from:

  • Unsized images in Fluid templates
  • Web font loading (Flash of Unstyled Text)
  • Dynamically injected content (ads, tracking scripts)
  • Late-loading content elements
  • Asynchronous extension output

CLS Targets

  • Good: < 0.1
  • Needs Improvement: 0.1 - 0.25
  • Poor: > 0.25

Identify CLS Issues

Using Chrome DevTools

  1. Open DevTools (F12)
  2. Click three dots → More tools → Rendering
  3. Enable Layout Shift Regions
  4. Reload page and watch for blue highlights

Measure CLS

page.jsFooterInline.700 = TEXT
page.jsFooterInline.700.value (
    // Measure CLS
    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('Layout Shift:', entry.value, entry.sources);
            }
        }

        console.log('Total CLS:', clsValue);

        // Send to analytics
        if (clsValue > 0) {
            gtag('event', 'web_vitals', {
                'metric_name': 'CLS',
                'metric_value': clsValue,
                'metric_rating': clsValue < 0.1 ? 'good' : 'poor'
            });
        }
    });

    observer.observe({type: 'layout-shift', buffered: true});
)

1. Image Sizing in TYPO3

Always Define Image Dimensions

Bad (Causes CLS):

<f:image image="{file}" alt="{file.alternative}" />

Good:

<f:image
    image="{file}"
    alt="{file.alternative}"
    width="800"
    height="600"
/>

TypoScript Image Configuration

lib.contentElement {
    settings {
        media {
            # Always render width/height attributes
            renderDimensionsAttributes = 1

            popup {
                linkParams.ATagParams.dataWrap = class="lightbox" data-width="{file:current:width}" data-height="{file:current:height}"
            }
        }
    }
}

# Standard image rendering
lib.image = IMAGE
lib.image {
    file {
        import.current = 1
        treatIdAsReference = 1
        width = 800c
        height = 600c
    }
    altText.field = alternative
    titleText.field = title

    # Ensure dimensions are rendered
    layoutKey = default
    layout {
        default {
            element = <img src="###SRC###" width="###WIDTH###" height="###HEIGHT###" alt="###ALT###" ###PARAMS### />
        }
    }
}

Fluid Template with Explicit Dimensions

<!-- Get image dimensions -->
<f:if condition="{file.properties.width}">
    <f:then>
        <f:image
            image="{file}"
            width="{file.properties.width}"
            height="{file.properties.height}"
            alt="{file.alternative}"
        />
    </f:then>
    <f:else>
        <!-- Fallback with fixed dimensions -->
        <f:image
            image="{file}"
            width="800"
            height="600"
            alt="{file.alternative}"
        />
    </f:else>
</f:if>

Responsive Images with Aspect Ratio

<!-- Maintain aspect ratio with CSS -->
<div class="image-container" style="aspect-ratio: {file.properties.width} / {file.properties.height};">
    <f:image
        image="{file}"
        width="{file.properties.width}"
        height="{file.properties.height}"
        alt="{file.alternative}"
        class="responsive-image"
    />
</div>

<style>
.image-container {
    position: relative;
    width: 100%;
}

.responsive-image {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}
</style>

DataProcessor for Image Dimensions

page.10 = FLUIDTEMPLATE
page.10 {
    file = EXT:your_sitepackage/Resources/Private/Templates/Page/Default.html

    dataProcessing {
        10 = TYPO3\CMS\Frontend\DataProcessing\FilesProcessor
        10 {
            references.fieldName = media
            as = images

            # Process image metadata
            processing {
                width = 800c
                height = 600c
            }
        }
    }
}

Use in Fluid:

<f:for each="{images}" as="image">
    <img
        src="{image.publicUrl}"
        width="{image.properties.dimensions.width}"
        height="{image.properties.dimensions.height}"
        alt="{image.alternative}"
    />
</f:for>

2. Font Loading Optimization

Font Display Strategy

TypoScript:

page.headerData {
    20 = TEXT
    20.value (
        <link rel="preconnect" href="https://fonts.googleapis.com">
        <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
        <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
    )
}

Or via CSS:

@font-face {
    font-family: 'CustomFont';
    src: url('/typo3conf/ext/your_sitepackage/Resources/Public/Fonts/custom.woff2') format('woff2');
    font-display: swap; /* Prevents invisible text */
    font-weight: 400;
    font-style: normal;
}

Preload Critical Fonts

page.headerData {
    15 = TEXT
    15.value (
        <link rel="preload" href="/typo3conf/ext/your_sitepackage/Resources/Public/Fonts/main-font.woff2" as="font" type="font/woff2" crossorigin>
    )
}

Self-Host Web Fonts

Download fonts and add to site package:

your_sitepackage/
└── Resources/
    └── Public/
        └── Fonts/
            ├── roboto-v30-latin-regular.woff2
            └── roboto-v30-latin-700.woff2

CSS:

@font-face {
    font-family: 'Roboto';
    src: url('../Fonts/roboto-v30-latin-regular.woff2') format('woff2');
    font-display: swap;
    font-weight: 400;
}

@font-face {
    font-family: 'Roboto';
    src: url('../Fonts/roboto-v30-latin-700.woff2') format('woff2');
    font-display: swap;
    font-weight: 700;
}

Fallback Font Matching

body {
    font-family: 'Roboto', 'Arial', 'Helvetica', sans-serif;
    /* Match Arial metrics to Roboto to minimize shift */
    font-size: 16px;
    line-height: 1.5;
}

/* Optional: Font loading class */
.fonts-loaded body {
    font-family: 'Roboto', sans-serif;
}

3. Reserve Space for Dynamic Content

Ads and Embeds

Bad (Causes CLS):

<div class="ad-container">
    <!-- Ad loads and shifts content -->
</div>

Good:

<div class="ad-container" style="min-height: 250px;">
    <!-- Ad loads into reserved space -->
</div>

TypoScript for Ad Slots:

lib.adSlot = COA
lib.adSlot {
    wrap = <div class="ad-slot" style="min-height: 250px; width: 300px;">|</div>

    10 = TEXT
    10.value = <!-- Ad code here -->
}

YouTube/Vimeo Embeds

<!-- Reserve 16:9 aspect ratio -->
<div class="video-container" style="aspect-ratio: 16/9; width: 100%;">
    <iframe
        src="https://www.youtube.com/embed/{videoId}"
        width="100%"
        height="100%"
        frameborder="0"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
        allowfullscreen
    ></iframe>
</div>

<style>
.video-container {
    position: relative;
    overflow: hidden;
}

.video-container iframe {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
}
</style>

Content Element Placeholders

<!-- While content loads -->
<f:if condition="{contentLoaded}">
    <f:then>
        {content}
    </f:then>
    <f:else>
        <div class="content-placeholder" style="min-height: 400px;">
            <div class="skeleton-loader"></div>
        </div>
    </f:else>
</f:if>

4. Navigation and Menu Stability

Fixed Header Heights

# Ensure header has fixed height
lib.header = COA
lib.header {
    wrap = <header class="site-header" style="height: 80px;">|</header>

    10 = HMENU
    10 {
        # Menu configuration
    }
}

CSS:

.site-header {
    height: 80px; /* Fixed height prevents shift */
    position: sticky;
    top: 0;
    z-index: 1000;
}

.site-header__logo {
    height: 60px; /* Fixed logo height */
    width: auto;
}
.menu-dropdown {
    /* Use transform instead of display to avoid layout shift */
    position: absolute;
    top: 100%;
    left: 0;
    transform: scaleY(0);
    transform-origin: top;
    transition: transform 0.2s ease;
}

.menu-item:hover .menu-dropdown {
    transform: scaleY(1);
}

5. Animation and Transition Best Practices

Use Transform and Opacity Only

Bad (Causes CLS):

.element {
    transition: height 0.3s;
}

.element:hover {
    height: 200px; /* Causes layout shift */
}

Good:

.element {
    transition: transform 0.3s, opacity 0.3s;
}

.element:hover {
    transform: scale(1.1); /* No layout shift */
    opacity: 0.8;
}

Accordion Content

<!-- Fluid Template -->
<div class="accordion-item">
    <button class="accordion-trigger" aria-expanded="false">
        {title}
    </button>
    <div class="accordion-content" style="max-height: 0; overflow: hidden;">
        <div class="accordion-inner" style="padding: 1rem;">
            {content}
        </div>
    </div>
</div>

<script>
document.querySelectorAll('.accordion-trigger').forEach(trigger => {
    trigger.addEventListener('click', function() {
        const content = this.nextElementSibling;
        const inner = content.querySelector('.accordion-inner');

        if (content.style.maxHeight === '0px' || !content.style.maxHeight) {
            // Expand
            content.style.maxHeight = inner.scrollHeight + 'px';
            this.setAttribute('aria-expanded', 'true');
        } else {
            // Collapse
            content.style.maxHeight = '0';
            this.setAttribute('aria-expanded', 'false');
        }
    });
});
</script>

6. Lazy Loading and Infinite Scroll

Proper Lazy Loading

<!-- Images below fold -->
<f:for each="{images}" as="image" iteration="iterator">
    <f:if condition="{iterator.cycle} > 3">
        <f:then>
            <f:image
                image="{image}"
                width="800"
                height="600"
                loading="lazy"
                alt="{image.alternative}"
            />
        </f:then>
        <f:else>
            <!-- First 3 images load normally -->
            <f:image
                image="{image}"
                width="800"
                height="600"
                loading="eager"
                alt="{image.alternative}"
            />
        </f:else>
    </f:if>
</f:for>

Infinite Scroll with Placeholders

// Reserve space before loading new items
const loadMore = () => {
    const placeholder = document.createElement('div');
    placeholder.className = 'loading-placeholder';
    placeholder.style.minHeight = '500px';
    container.appendChild(placeholder);

    // Fetch new content
    fetch('/api/load-more')
        .then(response => response.text())
        .then(html => {
            placeholder.outerHTML = html; // Replace placeholder
        });
};
page.jsFooterInline.710 = TEXT
page.jsFooterInline.710.value (
    // Cookie banner with no layout shift
    if (!document.cookie.includes('cookie_consent=')) {
        const banner = document.createElement('div');
        banner.className = 'cookie-banner';
        banner.style.cssText = 'position: fixed; bottom: 0; left: 0; right: 0; z-index: 9999;';
        banner.innerHTML = 'Cookie message here';
        document.body.appendChild(banner);
    }
)

CSS:

.cookie-banner {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    background: #333;
    color: #fff;
    padding: 1rem;
    transform: translateY(100%);
    transition: transform 0.3s ease;
    z-index: 9999;
}

.cookie-banner.show {
    transform: translateY(0);
}

8. Content Element Rendering

Consistent Content Element Heights

TypoScript:

lib.contentElement {
    templateRootPaths {
        0 = EXT:fluid_styled_content/Resources/Private/Templates/
        10 = EXT:your_sitepackage/Resources/Private/Templates/ContentElements/
    }

    settings {
        defaultContentElementHeight = 400
    }
}

Fluid Override:

<!-- TextMedia.html -->
<f:layout name="Default" />

<f:section name="Main">
    <div class="ce-textmedia" style="min-height: 300px;">
        <f:if condition="{gallery.rows}">
            <f:render partial="Media/Gallery" arguments="{gallery: gallery}" />
        </f:if>
        <f:if condition="{data.bodytext}">
            <div class="ce-bodytext">
                <f:format.html>{data.bodytext}</f:format.html>
            </div>
        </f:if>
    </div>
</f:section>

9. Extension-Specific CLS Fixes

News Extension

<!-- List.html override -->
<f:for each="{news}" as="newsItem">
    <article class="news-item" style="min-height: 200px;">
        <f:if condition="{newsItem.media.0}">
            <div class="news-image" style="aspect-ratio: 16/9;">
                <f:image
                    image="{newsItem.media.0}"
                    width="400"
                    height="225"
                    alt="{newsItem.media.0.alternative}"
                />
            </div>
        </f:if>
        <h2>{newsItem.title}</h2>
        <p>{newsItem.teaser -> f:format.crop(maxCharacters: 150)}</p>
    </article>
</f:for>

Powermail Forms

plugin.tx_powermail {
    view {
        templateRootPaths {
            10 = EXT:your_sitepackage/Resources/Private/Templates/Powermail/
        }
    }
}

Form Template:

<!-- Reserve minimum height -->
<div class="powermail-form-container" style="min-height: 600px;">
    {form}
</div>

10. Testing and Monitoring

Real User CLS Monitoring

page.jsFooterInline.720 = TEXT
page.jsFooterInline.720.value (
    // Monitor and report CLS
    let clsValue = 0;
    const clsObserver = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
            if (!entry.hadRecentInput) {
                clsValue += entry.value;
            }
        }
    });

    clsObserver.observe({type: 'layout-shift', buffered: true});

    // Report on page unload
    window.addEventListener('beforeunload', () => {
        if (clsValue > 0) {
            navigator.sendBeacon('/api/cls-report', JSON.stringify({
                cls: clsValue,
                page: window.location.pathname,
                timestamp: Date.now()
            }));

            // Or send to Google Analytics
            gtag('event', 'web_vitals', {
                'metric_name': 'CLS',
                'metric_value': clsValue,
                'page_path': window.location.pathname
            });
        }
    });
)

Automated CLS Testing

Using Lighthouse CI:

{
  "ci": {
    "collect": {
      "url": ["https://your-typo3-site.com/"],
      "numberOfRuns": 3
    },
    "assert": {
      "assertions": {
        "cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}]
      }
    }
  }
}

Common TYPO3 CLS Issues

Issue Cause Solution
Hero image shift No dimensions set Add width/height to Fluid image tag
Font swap FOUT/FOIT Use font-display: swap
Menu dropdown Height animation Use transform instead
Ad injection No reserved space Set min-height on container
Late content load Async extension Reserve space or use placeholders
Cookie banner Pushes content down Use fixed positioning

Performance Impact

Optimizing CLS has minimal performance cost:

  • CSS aspect-ratio: Modern browsers, no overhead
  • Font-display swap: Better UX, no performance hit
  • Transform animations: GPU-accelerated, faster than layout changes

Next Steps