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
- Open DevTools (F12)
- Click three dots → More tools → Rendering
- Enable Layout Shift Regions
- 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;
}
Dropdown Menu Positioning
.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
});
};
7. Cookie Banners and Overlays
Fixed Position Cookie Banner
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/
}
}
}
<!-- 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