Cumulative Layout Shift (CLS) measures visual stability during page load. Ghost sites commonly experience layout shifts from unsized images, Ghost Portal widget, embedded content, and third-party scripts. This guide provides Ghost-specific solutions to achieve CLS < 0.1.
Understanding CLS in Ghost
What Causes Layout Shift in Ghost
Common CLS culprits on Ghost sites:
- Unsized Images - Feature images, post card images without dimensions
- Ghost Portal Widget - Member signup widget loading late
- Web Fonts - Custom fonts causing text reflow
- Embedded Content - YouTube videos, tweets, Instagram posts
- Dynamic Ads - Ad units without reserved space
- Code Injection - Third-party widgets loading asynchronously
- Newsletter Signup Forms - Dynamic form widgets
Target CLS Performance
- Good: CLS < 0.1
- Needs Improvement: CLS 0.1 - 0.25
- Poor: CLS > 0.25
Measure Current CLS
Using Google PageSpeed Insights
- Navigate to PageSpeed Insights
- Enter your Ghost site URL
- Click Analyze
- Review Cumulative Layout Shift metric
- Click Expand view to see which elements cause shifts
Using Chrome DevTools
- Open your Ghost site in Chrome
- Open DevTools (F12)
- Click Performance tab
- Check Experience section
- Click Record and reload page
- Stop recording
- Review Layout Shifts in timeline (red bars)
Using Web Vitals Extension
- Install Web Vitals Chrome Extension
- Navigate to your Ghost site
- Extension shows real-time CLS in toolbar
- High CLS appears in red
Ghost-Specific CLS Fixes
1. Size Ghost Images Properly
Ghost's \{\{img_url\}\} helper doesn't automatically add width/height attributes. Add them manually.
Feature Images with Explicit Dimensions
{{!-- In post.hbs or page.hbs --}}
{{#if feature_image}}
<img
src="{{img_url feature_image size="xl"}}"
alt="{{title}}"
width="2000"
height="1200"
{{!-- Add explicit dimensions to prevent layout shift --}}
>
{{/if}}
Determine Image Dimensions:
// Run in browser console on your post
var img = document.querySelector('.post-feature-image');
console.log('Width:', img.naturalWidth);
console.log('Height:', img.naturalHeight);
// Use these values in your theme
Dynamic Aspect Ratio
For images with varying dimensions, use aspect-ratio:
{{#if feature_image}}
<div class="feature-image-container" style="aspect-ratio: 16/9;">
<img
src="{{img_url feature_image size="xl"}}"
alt="{{title}}"
style="width: 100%; height: 100%; object-fit: cover;"
>
</div>
{{/if}}
Or with CSS:
/* In theme CSS */
.feature-image-container {
aspect-ratio: 16 / 9;
width: 100%;
}
.feature-image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
Post Card Images (Homepage)
{{!-- In partials/post-card.hbs or index.hbs --}}
{{#if feature_image}}
<a href="{{url}}" class="post-card-image-link">
<div class="post-card-image" style="aspect-ratio: 3/2;">
<img
src="{{img_url feature_image size="m"}}"
alt="{{title}}"
loading="lazy"
{{!-- Lazy load non-LCP images --}}
>
</div>
</a>
{{/if}}
/* In theme CSS */
.post-card-image {
aspect-ratio: 3 / 2;
overflow: hidden;
background-color: #f0f0f0; /* Placeholder color */
}
.post-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
2. Reserve Space for Ghost Portal
Ghost Portal loads asynchronously and can cause layout shift when it appears.
Allocate Portal Button Space
/* In theme CSS */
.gh-portal-trigger {
min-height: 40px;
min-width: 100px;
display: inline-block;
}
/* Reserve space for Portal frame */
.ghost-portal-frame {
position: fixed !important;
/* Portal is fixed, doesn't affect layout */
}
Prevent Portal-Induced Shifts
{{!-- In default.hbs --}}
<script>
// Ensure Portal doesn't shift layout
if (window.ghost) {
window.ghost.init({
buttonStyle: 'fixed', // Use fixed positioning
});
}
</script>
3. Optimize Web Fonts
Web fonts loading can cause text to reflow (FOIT or FOUT).
Use font-display: swap
@font-face {
font-family: 'CustomFont';
src: url('font.woff2') format('woff2');
font-display: swap; /* Prevent invisible text, allow fallback */
}
Match Fallback Font Metrics
Use size-adjust to match fallback font size to web font:
@font-face {
font-family: 'CustomFont';
src: url('font.woff2') format('woff2');
font-display: swap;
}
/* Adjust fallback font to match custom font */
@font-face {
font-family: 'CustomFont-Fallback';
src: local('Arial');
size-adjust: 105%; /* Adjust to match custom font */
ascent-override: 95%;
descent-override: 25%;
line-gap-override: 0%;
}
body {
font-family: 'CustomFont', 'CustomFont-Fallback', Arial, sans-serif;
}
Calculate size-adjust:
- Use Fontaine or Fallback Font Generator
Preload Critical Fonts
<head>
<link rel="preload" href="{{asset "fonts/custom-font.woff2"}}" as="font" type="font/woff2" crossorigin>
{{ghost_head}}
</head>
4. Embedded Content (YouTube, Twitter, etc.)
Embeds without size reservations cause major layout shifts.
Size YouTube Embeds
{{!-- In post content, wrap YouTube embeds --}}
<div class="video-container">
<iframe
src="https://www.youtube.com/embed/VIDEO_ID"
frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
/* Reserve 16:9 aspect ratio */
.video-container {
position: relative;
aspect-ratio: 16 / 9;
width: 100%;
}
.video-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
Auto-Wrap Embeds in Ghost Content
{{!-- In default.hbs or post.hbs --}}
<div class="post-content">
{{{content}}}
</div>
<script>
// Wrap YouTube iframes in aspect-ratio containers
document.querySelectorAll('.post-content iframe[src*="youtube.com"], .post-content iframe[src*="vimeo.com"]').forEach(function(iframe) {
var wrapper = document.createElement('div');
wrapper.className = 'video-container';
iframe.parentNode.insertBefore(wrapper, iframe);
wrapper.appendChild(iframe);
});
</script>
Twitter Embeds
<!-- Reserve space before Twitter widget loads -->
<div class="twitter-embed-container" style="min-height: 500px;">
<blockquote class="twitter-tweet">
<a href="https://twitter.com/user/status/123"></a>
</blockquote>
</div>
<script async src="https://platform.twitter.com/widgets.js"></script>
5. Newsletter Signup Forms
Dynamic forms can shift content when they load.
Reserve Form Space
{{!-- In theme where newsletter form appears --}}
<div class="newsletter-container" style="min-height: 200px;">
{{!-- Ghost newsletter form or third-party widget --}}
<form class="newsletter-form">
<input type="email" placeholder="Your email">
<button type="submit">Subscribe</button>
</form>
</div>
.newsletter-container {
min-height: 200px; /* Reserve space */
display: flex;
align-items: center;
justify-content: center;
}
.newsletter-form {
width: 100%;
max-width: 500px;
}
6. Dynamic Ads and Third-Party Widgets
Ads loading late cause significant layout shifts.
Reserve Ad Space
<!-- Ad placeholder with explicit size -->
<div class="ad-container" style="width: 728px; height: 90px; margin: 20px auto; background: #f0f0f0;">
<div id="ad-slot">
<!-- Ad script loads here -->
</div>
</div>
Lazy Load Ads Below Fold
<div class="ad-container" data-ad-slot="below-fold" style="min-height: 250px;">
<!-- Ad loads when scrolled into view -->
</div>
<script>
// Intersection Observer to load ads when visible
var adObserver = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
var adSlot = entry.target;
// Load ad script here
adObserver.unobserve(adSlot);
}
});
});
document.querySelectorAll('[data-ad-slot]').forEach(function(ad) {
adObserver.observe(ad);
});
</script>
7. Prevent Code Injection Layout Shifts
Scripts added via Ghost Code Injection can cause shifts.
Load Non-Critical Scripts Asynchronously
<!-- In Ghost Code Injection: Site Footer -->
<script>
// Load widget after page fully renders
window.addEventListener('load', function() {
setTimeout(function() {
// Load widget script here
}, 100);
});
</script>
Reserve Space for Injected Widgets
<!-- In Code Injection: Site Header -->
<style>
.widget-container {
min-height: 150px;
display: block;
}
</style>
<!-- In Code Injection: Site Footer -->
<div class="widget-container" id="my-widget"></div>
<script src="https://example.com/widget.js"></script>
8. Ghost Member Context Loading
Member-specific content can shift layout when loaded.
Use CSS Visibility Instead of Display
{{!-- Bad: Causes layout shift --}}
{{#member}}
<div class="member-content" style="display: block;">
Member-only content
</div>
{{/member}}
{{!-- Good: Reserves space, no shift --}}
<div class="member-content" style="{{#unless member}}visibility: hidden; height: 0; overflow: hidden;{{/unless}}">
Member-only content
</div>
Skeleton Placeholders
{{^member}}
<div class="member-content-skeleton">
<!-- Placeholder while checking member status -->
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
</div>
{{/member}}
{{#member}}
<div class="member-content">
<!-- Actual member content -->
</div>
{{/member}}
.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: 10px;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Advanced CLS Optimizations
Use CSS Container Queries
For responsive layouts that don't shift:
.post-card-container {
container-type: inline-size;
}
@container (min-width: 600px) {
.post-card {
display: flex;
flex-direction: row;
}
}
Implement Content-Visibility
Defer rendering of off-screen content:
.post-card {
content-visibility: auto;
contain-intrinsic-size: 500px; /* Approximate height */
}
Use Transform for Animations
Avoid layout-triggering CSS properties:
/* Bad: Causes layout shift */
.element:hover {
margin-top: 10px;
}
/* Good: Uses transform (composited layer) */
.element:hover {
transform: translateY(-10px);
}
Ghost Theme-Specific Fixes
Fix Casper Theme CLS Issues
Casper (default Ghost theme) can have CLS problems:
/* Fix post card images */
.post-card-image {
aspect-ratio: 3 / 2;
}
/* Fix feature image */
.post-full-image {
aspect-ratio: 16 / 9;
}
.post-full-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
Fix Custom Theme Issues
Common custom theme problems:
/* Prevent header from shifting */
.site-header {
min-height: 80px; /* Reserve header space */
}
/* Prevent footer from shifting */
.site-footer {
margin-top: auto; /* Sticky footer, no shift */
}
/* Fix navigation menu */
.site-nav {
min-height: 60px;
}
Testing and Validation
Manual CLS Testing
- Open Chrome DevTools → Performance
- Click Record
- Reload page and wait for full load
- Stop recording
- Review Layout Shifts:
- Red bars in timeline indicate shifts
- Hover over bars to see affected elements
- Click bar to see screenshot of shift
Automated CLS Testing
Lighthouse CI
# Install
npm install -g @lhci/cli
# Run CLS audit
lhci autorun --collect.url=https://yoursite.com --collect.settings.preset=desktop
Web Vitals Monitoring
// Add to Ghost theme (in default.hbs)
import {getCLS} from 'web-vitals';
getCLS(function(metric) {
// Log CLS to console
console.log('CLS:', metric.value);
// Send to analytics
if (typeof gtag !== 'undefined') {
gtag('event', 'web_vitals', {
event_category: 'Web Vitals',
event_label: metric.id,
value: Math.round(metric.value * 1000), // Convert to milliseconds
metric_name: 'CLS'
});
}
});
Continuous Monitoring
- Navigate to Experience → Core Web Vitals
- Review CLS issues by URL
- Click Open Report for details
- Fix issues and request re-crawl
Common Ghost CLS Issues
Issue: Feature Image Shifts Content
Problem: Large feature image loads and pushes content down Solution:
- Add explicit width/height attributes
- Use aspect-ratio CSS property
- Implement skeleton placeholder
Issue: Ghost Portal Button Shifts
Problem: Portal button appears late and shifts navigation Solution:
- Reserve button space with min-height/width
- Use fixed positioning for Portal frame
- Load Portal immediately (not deferred)
Issue: Post Cards Shift on Homepage
Problem: Post card images load and change card height Solution:
- Apply aspect-ratio to all post card images
- Use consistent image sizes
- Implement skeleton cards
Issue: Newsletter Form Shifts Footer
Problem: Newsletter widget loads and pushes footer down Solution:
- Reserve form space with min-height
- Use fixed-height container
- Load form synchronously in footer
Issue: Web Fonts Cause Text Reflow
Problem: Text size changes when custom font loads Solution:
- Use font-display: swap
- Match fallback font metrics with size-adjust
- Preload critical fonts
Debugging CLS
Identify Shifting Elements
// Add to Ghost theme to log layout shifts
var observer = new PerformanceObserver(function(list) {
for (var entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // Ignore user-initiated shifts
console.log('Layout Shift:', entry.value);
console.log('Affected elements:', entry.sources);
entry.sources.forEach(function(source) {
console.log('Element:', source.node);
console.log('Shift amount:', source.currentRect, source.previousRect);
});
}
});
observer.observe({type: 'layout-shift', buffered: true});
Visual CLS Debugging
/* Highlight elements that cause layout shifts */
* {
outline: 1px solid rgba(255, 0, 0, 0.1) !important;
}
/* Helps identify which elements move */
Next Steps
- LCP Optimization - Improve largest contentful paint
- Tracking Events Not Firing - Debug tracking issues
- Ghost Performance Guide - Overall performance optimization