Cumulative Layout Shift (CLS) measures visual stability during page load. High CLS frustrates users and negatively impacts SEO rankings.
Target: CLS under 0.1 Good: Under 0.1 | Needs Improvement: 0.1-0.25 | Poor: Over 0.25
For general CLS concepts, see the global CLS guide.
Concrete CMS-Specific CLS Issues
1. Images Without Dimensions
The most common CLS issue is images loading without explicit width/height attributes.
Problem: Images in blocks don't have dimensions set, causing layout shift when they load.
Diagnosis:
- Run PageSpeed Insights
- Look for "Image elements do not have explicit width and height"
- Check for layout shifts in Chrome DevTools Performance tab
Solutions:
A. Set Dimensions in Image Blocks
Always include width and height attributes:
<?php
// In image block template
$image = $this->controller->get('image');
if ($image) {
$thumbnail = $image->getThumbnail('large');
?>
<img
src="<?php echo $thumbnail->src; ?>"
width="<?php echo $thumbnail->width; ?>"
height="<?php echo $thumbnail->height; ?>"
alt="<?php echo $image->getTitle(); ?>"
>
<?php
}
?>
B. Use Aspect Ratio Boxes
For responsive images, maintain aspect ratio:
/* In your theme CSS */
.image-container {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
overflow: hidden;
}
.image-container img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
<!-- In template -->
<div class="image-container">
<img
src="<?php echo $thumbnail->src; ?>"
alt="<?php echo $image->getTitle(); ?>"
>
</div>
C. CSS Aspect-Ratio Property
Modern browsers support aspect-ratio:
.responsive-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
<img
src="<?php echo $thumbnail->src; ?>"
class="responsive-image"
alt="<?php echo $image->getTitle(); ?>"
>
2. Dynamic Block Content
Blocks that load content dynamically can cause layout shifts.
Problem: AJAX-loaded content, tabs, accordions, or collapsible sections shifting layout.
Common Culprits:
- FAQ blocks with expanding answers
- Tabbed content blocks
- AJAX-loaded page lists
- Social media feed blocks
- Related content blocks
Solutions:
A. Reserve Space for Dynamic Content
/* Reserve minimum height for dynamic content */
.dynamic-content-block {
min-height: 300px; /* Adjust based on expected content */
}
/* For AJAX loading blocks */
.ajax-content-wrapper {
min-height: 400px;
position: relative;
}
.ajax-content-wrapper.loading::before {
content: '';
display: block;
height: 400px;
}
B. Load Content Before Page Render
Instead of AJAX loading after page load, server-side render the content:
<?php
// In custom block controller
public function view()
{
// Load related pages server-side
$pageList = new \Concrete\Core\Page\PageList();
$pageList->filterByPageTypeHandle('blog_entry');
$pageList->setItemsPerPage(3);
$pages = $pageList->getResults();
$this->set('relatedPages', $pages);
}
?>
<!-- In block view template -->
<div class="related-posts">
<?php foreach ($relatedPages as $page) { ?>
<div class="post-item">
<?php echo $page->getCollectionName(); ?>
</div>
<?php } ?>
</div>
C. Use CSS Transitions Instead of JavaScript
For accordions and collapsible content:
/* Smooth transitions without layout shift */
.accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.accordion.active .accordion-content {
max-height: 500px; /* Set to max expected height */
}
3. Web Fonts Causing Shifts
Custom fonts loading can cause text to shift (FOIT/FOUT).
Problem: Text initially renders in fallback font, then shifts when custom font loads.
Solutions:
A. Use font-display: swap
@font-face {
font-family: 'CustomFont';
src: url('fonts/custom-font.woff2') format('woff2');
font-display: swap; /* Prevent invisible text */
font-weight: 400;
font-style: normal;
}
B. Preload Critical Fonts
<!-- In page template <head> -->
<link
rel="preload"
href="<?php echo $this->getThemePath(); ?>/fonts/custom-font.woff2"
as="font"
type="font/woff2"
crossorigin
>
C. Match Fallback Font Metrics
Reduce shift by matching fallback font size:
body {
font-family: 'CustomFont', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
/* Adjust fallback to match custom font metrics */
font-size: 16px;
line-height: 1.5;
}
/* While font is loading */
.fonts-loading body {
font-size: 16px; /* Match custom font size */
letter-spacing: 0; /* Adjust if needed */
}
4. Ads and Third-Party Embeds
Third-party content can cause unexpected layout shifts.
Common Issues:
- Ad slots without reserved space
- Social media embeds (Twitter, Instagram, Facebook)
- YouTube videos without aspect ratio containers
- Comment systems (Disqus, etc.)
Solutions:
A. Reserve Space for Ad Slots
/* Reserve exact space for ad */
.ad-slot {
width: 300px;
height: 250px;
background: #f0f0f0; /* Placeholder while loading */
}
<div class="ad-slot">
<!-- Ad code here -->
</div>
B. Video Embeds with Aspect Ratio
.video-container {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
height: 0;
overflow: hidden;
}
.video-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
<!-- In custom video block template -->
<div class="video-container">
<iframe
src="https://www.youtube.com/embed/<?php echo $videoID; ?>"
frameborder="0"
allowfullscreen
></iframe>
</div>
C. Social Media Embeds
For Twitter, Instagram, Facebook embeds:
/* Reserve space for social embed */
.social-embed-wrapper {
min-height: 500px;
background: #f9f9f9;
}
Use placeholder while loading:
<div class="social-embed-wrapper">
<div class="placeholder">Loading...</div>
<!-- Social embed code -->
</div>
5. Block Edit Mode vs. View Mode
Content that looks different in edit mode vs. view mode can cause shifts.
Problem: Blocks styled differently when editing vs. viewing, causing unexpected layout.
Solutions:
A. Consistent Styling
/* Ensure consistent block styling */
.ccm-block-edit .custom-block,
.custom-block {
min-height: 200px;
padding: 20px;
}
B. Test in View Mode
Always test pages in view mode (logged out or preview):
<?php
// In template, check if in edit mode
if ($c->isEditMode()) {
// Apply edit-specific styles carefully
}
?>
6. Carousel and Slider Blocks
Carousels often cause layout shifts during initialization.
Problem: Carousel shows all slides stacked, then shifts when JavaScript initializes.
Solutions:
A. Hide Non-Active Slides with CSS
/* Hide slides until carousel initializes */
.carousel-block .slide {
display: none;
}
.carousel-block .slide.active,
.carousel-block.initialized .slide {
display: block;
}
/* Reserve height for carousel */
.carousel-block {
min-height: 400px;
}
B. Initialize Before Page Render
Load carousel in <head> instead of at bottom:
<?php
// In template
$this->requireAsset('javascript', 'carousel');
?>
C. Use CSS-Only Carousels
Consider CSS-only solutions to avoid JavaScript initialization delays:
/* Simple CSS carousel using scroll-snap */
.css-carousel {
display: flex;
overflow-x: scroll;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
}
.css-carousel .slide {
flex: 0 0 100%;
scroll-snap-align: start;
}
7. Lazy-Loaded Images
Incorrectly implemented lazy loading can cause layout shifts.
Problem: Lazy-loaded images without dimensions shift layout when they load.
Solutions:
A. Always Include Dimensions
<?php
$image = $this->controller->get('image');
$thumbnail = $image->getThumbnail('large');
?>
<img
src="<?php echo $thumbnail->src; ?>"
width="<?php echo $thumbnail->width; ?>"
height="<?php echo $thumbnail->height; ?>"
loading="lazy"
alt="<?php echo $image->getTitle(); ?>"
>
B. Use Placeholder Images
Low-quality image placeholders (LQIP):
<?php
$placeholder = $image->getThumbnail('tiny'); // 20x20 thumbnail
$full = $image->getThumbnail('large');
?>
<img
src="<?php echo $placeholder->src; ?>"
data-src="<?php echo $full->src; ?>"
width="<?php echo $full->width; ?>"
height="<?php echo $full->height; ?>"
class="lazy-image"
alt="<?php echo $image->getTitle(); ?>"
>
8. Sticky Headers and Footers
Sticky elements can cause shifts when they activate.
Problem: Header becomes fixed/sticky, shifting content below.
Solutions:
A. Reserve Space for Sticky Header
/* When header becomes sticky, add padding to body */
body.sticky-header {
padding-top: 80px; /* Header height */
}
header.sticky {
position: fixed;
top: 0;
width: 100%;
z-index: 1000;
}
// Add class when scrolling
window.addEventListener('scroll', function() {
if (window.scrollY > 100) {
document.body.classList.add('sticky-header');
document.querySelector('header').classList.add('sticky');
} else {
document.body.classList.remove('sticky-header');
document.querySelector('header').classList.remove('sticky');
}
});
B. Use transform Instead of position
/* Less likely to cause layout shift */
header {
transform: translateY(0);
transition: transform 0.3s ease;
}
header.sticky {
transform: translateY(-100%);
}
9. Form Blocks
Forms with validation messages can cause layout shifts.
Problem: Error messages appear, pushing content down.
Solutions:
A. Reserve Space for Error Messages
/* Reserve space for validation message */
.form-group {
margin-bottom: 30px; /* Includes space for error */
}
.error-message {
min-height: 20px;
margin-top: 5px;
}
<!-- In form block template -->
<div class="form-group">
<input type="text" name="email" required>
<div class="error-message">
<!-- Error appears here without shifting layout -->
</div>
</div>
B. Use Absolute Positioning for Errors
.form-group {
position: relative;
margin-bottom: 20px;
}
.error-message {
position: absolute;
bottom: -20px;
left: 0;
font-size: 12px;
color: red;
}
10. Grid and Flexbox Layouts
Improperly configured grid layouts can shift.
Problem: Grid items adjust size when content loads.
Solutions:
A. Set Explicit Grid Template Rows/Columns
.grid-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(auto-fill, 300px); /* Fixed row height */
gap: 20px;
}
B. Use min-height on Grid Items
.grid-item {
min-height: 300px;
display: flex;
flex-direction: column;
}
Testing & Monitoring
Test CLS
Tools:
- PageSpeed Insights - Field and lab CLS data
- Chrome DevTools - Performance tab with Layout Shift events
- WebPageTest - Detailed CLS analysis
- Web Vitals Extension - Real-time CLS monitoring
Test Multiple Pages:
- Homepage
- Blog posts
- Form pages
- Landing pages with dynamic content
Test Different Scenarios:
- Slow 3G connection (to see loading shifts)
- Fast connection
- Mobile devices
- Different viewport sizes
Debug CLS in Chrome DevTools
- Open DevTools (F12)
- Go to Performance tab
- Click Record
- Reload page
- Stop recording
- Look for red Layout Shift events in timeline
- Click on events to see which elements shifted
Monitor CLS Over Time
- Core Web Vitals report
- Shows pages with CLS issues
- Real user data
Chrome User Experience Report (CrUX):
- Field data from real users
- 28-day rolling average
Quick Wins Checklist
Start here for immediate CLS improvements:
- Add width/height to all images
- Use aspect-ratio boxes for responsive images
- Set
font-display: swapfor web fonts - Reserve space for ads and embeds
- Add min-height to dynamic content blocks
- Fix carousel initialization
- Reserve space for form error messages
- Use CSS transitions instead of JavaScript animations
- Test in Chrome DevTools Performance tab
- Add explicit grid dimensions
When to Hire a Developer
Consider hiring a Concrete CMS developer if:
- CLS consistently over 0.25 after basic fixes
- Complex custom blocks need optimization
- Theme requires significant restructuring
- JavaScript-heavy blocks causing shifts
- Need custom block development with CLS in mind
Find Developers: Concrete CMS Marketplace
Next Steps
For general CLS optimization strategies, see CLS Optimization Guide.