General Guide: See Global CLS Guide for universal concepts and fixes.
AEM-Specific CLS Causes
1. Experience Fragments Loading
Async Experience Fragment injection:
<!-- Problem: Fragment loads after page render -->
<div data-sly-resource="${'xf' @ resourceType='cq/experience-fragments/editor/components/experiencefragment'}">
</div>
2. Personalization Content
Target/Adobe Target content swapping:
<!-- Content changes after personalization loads -->
<div class="personalization-container">
${personalizedContent}
</div>
3. DAM Images Without Dimensions
Images without explicit size:
<!-- Problem: No dimensions specified -->
<img src="${asset.path}"/>
4. Web Fonts (Adobe Fonts)
Font loading causing text reflow:
/* FOUT from Adobe Fonts */
@import url("https://use.typekit.net/abc123.css");
AEM-Specific Fixes
Fix 1: Reserve Space for Experience Fragments
Set minimum dimensions:
<div class="xf-container" style="min-height: 300px;">
<sly data-sly-resource="${'xf' @ resourceType='cq/experience-fragments/editor/components/experiencefragment'}"/>
</div>
Or use skeleton screens:
<div class="xf-container">
<sly data-sly-resource="${'xf' @ resourceType='...'}"
data-sly-unwrap="${xf.content}">
<!-- Skeleton fallback -->
<div class="skeleton-xf" data-sly-test="${!xf.content}">
<div class="skeleton-block"></div>
<div class="skeleton-block"></div>
</div>
</sly>
</div>
.xf-container {
min-height: 300px;
}
.skeleton-xf {
animation: pulse 1.5s infinite;
}
.skeleton-block {
height: 100px;
background: #f0f0f0;
margin-bottom: 16px;
border-radius: 4px;
}
Fix 2: Pre-hide Personalization Containers
Use CSS to prevent layout shift:
/* Pre-hide until personalization loads */
.personalization-container {
opacity: 0;
transition: opacity 0.2s ease;
min-height: 200px;
}
.personalization-container.loaded {
opacity: 1;
}
// Client-side
window.targetPageParams = function() {
return {
"at_property": "your-property-token"
};
};
// After Target delivers
adobe.target.getOffers({}).then(function() {
document.querySelectorAll('.personalization-container').forEach(function(el) {
el.classList.add('loaded');
});
});
Fix 3: Use Core Image with Dimensions
Core Image component handles dimensions:
<div data-sly-resource="${'image' @ resourceType='core/wcm/components/image/v3/image'}">
</div>
Or specify in custom components:
<sly data-sly-use.asset="${'com.mysite.core.models.AssetModel'}">
<img src="${asset.src}"
width="${asset.width}"
height="${asset.height}"
alt="${asset.alt}"
loading="lazy"
style="aspect-ratio: ${asset.width}/${asset.height};">
</sly>
@Model(adaptables = Resource.class)
public class AssetModel {
@Inject
private Resource resource;
public int getWidth() {
Asset asset = resource.adaptTo(Asset.class);
return (int) asset.getMetadataValue("tiff:ImageWidth");
}
public int getHeight() {
Asset asset = resource.adaptTo(Asset.class);
return (int) asset.getMetadataValue("tiff:ImageLength");
}
}
Fix 4: Optimize Font Loading
Use font-display for Adobe Fonts:
<!-- Preload critical font -->
<link rel="preload"
href="https://use.typekit.net/af/font-file.woff2"
as="font"
type="font/woff2"
crossorigin>
<style>
@font-face {
font-family: 'MySiteFont';
src: url('https://use.typekit.net/af/font-file.woff2') format('woff2');
font-display: optional;
}
</style>
Or use system font fallback:
body {
font-family: 'Adobe Clean', -apple-system, BlinkMacSystemFont, sans-serif;
}
Fix 5: Stabilize Header/Navigation
Fixed dimensions for navigation:
.site-header {
height: 80px; /* Fixed height */
position: fixed;
top: 0;
width: 100%;
}
.main-content {
padding-top: 80px; /* Match header height */
}
/* User account area */
.user-account {
min-width: 120px; /* Prevent shift when logged in */
}
Fix 6: Handle Lazy-Loaded Components
Use IntersectionObserver with placeholders:
// Core Components lazy loading
var lazyComponents = document.querySelectorAll('[data-cmp-lazy]');
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
var component = entry.target;
// Maintain dimensions during load
component.style.minHeight = component.offsetHeight + 'px';
loadComponent(component).then(function() {
component.style.minHeight = '';
});
observer.unobserve(component);
}
});
}, { rootMargin: '100px' });
lazyComponents.forEach(function(el) {
observer.observe(el);
});
Monitoring CLS
import {onCLS} from 'web-vitals';
onCLS(function(metric) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'web_vitals',
'vital_name': 'CLS',
'vital_value': metric.value,
'vital_rating': metric.rating
});
});
CLS Targets
| Rating | CLS Value |
|---|---|
| Good | ≤ 0.1 |
| Needs Improvement | 0.1 - 0.25 |
| Poor | > 0.25 |