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

Fix CLS Issues on Ghost (Layout Shift)

Stabilize Ghost layouts by sizing Handlebars template images, preloading theme fonts, and reserving space for Portal member widgets.

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

  1. Navigate to PageSpeed Insights
  2. Enter your Ghost site URL
  3. Click Analyze
  4. Review Cumulative Layout Shift metric
  5. Click Expand view to see which elements cause shifts

Using Chrome DevTools

  1. Open your Ghost site in Chrome
  2. Open DevTools (F12)
  3. Click Performance tab
  4. Check Experience section
  5. Click Record and reload page
  6. Stop recording
  7. Review Layout Shifts in timeline (red bars)

Using Web Vitals Extension

  1. Install Web Vitals Chrome Extension
  2. Navigate to your Ghost site
  3. Extension shows real-time CLS in toolbar
  4. 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:

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

  1. Open Chrome DevTools → Performance
  2. Click Record
  3. Reload page and wait for full load
  4. Stop recording
  5. 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

Google Search Console:

  1. Navigate to Experience → Core Web Vitals
  2. Review CLS issues by URL
  3. Click Open Report for details
  4. 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

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