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

Fix CLS Issues on Boltcms (Layout Shift)

Stabilize Bolt CMS layouts by sizing images in Twig templates, preloading web fonts, and reserving space for dynamic content types.

General Guide: See Global CLS Guide for universal concepts and fixes.

What is CLS?

Cumulative Layout Shift measures visual stability. Google recommends CLS under 0.1. Bolt CMS (built on Symfony/Twig) generates CLS primarily from on-the-fly thumbnail generation, dynamic content type rendering, and extension-injected elements.

Bolt CMS-Specific CLS Causes

  • Thumbnail generation delay -- Bolt generates thumbnails on first request; the browser receives an image with unknown dimensions until generation completes, causing reflow
  • Content type field variability -- records with optional fields (hero images, embedded videos) render at different heights depending on which fields are populated
  • Extension-injected widgets -- Bolt extensions can inject HTML via Twig events (before_content, after_content) that shifts existing content
  • Font loading from theme assets -- themes loading custom fonts via CSS without font-display: swap cause FOUT shifts
  • Dynamic embeds in HTML fields -- YouTube, Vimeo, or iframe embeds in content fields render without reserved dimensions

Fixes

1. Enforce Image Dimensions in Twig Templates

Use Bolt's thumbnail filter with explicit dimensions:

{# BAD: No dimensions, causes CLS during thumbnail generation #}
<img src="{{ record.field('image')|thumbnail }}">

{# GOOD: Explicit dimensions with aspect-ratio #}
{% set img = record.field('image') %}
{% if img is not empty %}
  <img
    src="{{ img|thumbnail(800, 450, 'crop') }}"
    width="800"
    height="450"
    alt="{{ record.field('title') }}"
    loading="lazy"
    style="aspect-ratio: 800 / 450;"
  >
{% endif %}

For content listings where images may or may not exist:

{# Consistent card height regardless of image presence #}
<div class="card" style="min-height: 350px;">
  {% if record.field('image') is not empty %}
    <div class="card-image" style="aspect-ratio: 16/9; overflow: hidden; background: #e5e5e5;">
      <img
        src="{{ record.field('image')|thumbnail(400, 225, 'crop') }}"
        width="400" height="225"
        alt="{{ record.field('title') }}"
        loading="lazy"
        style="width: 100%; height: 100%; object-fit: cover;"
      >
    </div>
  {% else %}
    <div class="card-image-placeholder" style="aspect-ratio: 16/9; background: #e5e5e5;"></div>
  {% endif %}
  <div class="card-content">
    <h3>{{ record.field('title') }}</h3>
  </div>
</div>

2. Reserve Space for Content Type Fields

Bolt's flexible content types mean records have different field combinations. Set minimum heights:

/* Base layout containment for record display */
.record-content {
  contain: layout;
}

/* Reserve space for optional hero image field */
.record-hero {
  min-height: 0; /* Collapses when empty */
}
.record-hero:not(:empty) {
  aspect-ratio: 16 / 9;
  overflow: hidden;
}

/* Video embed containers */
.record-content iframe,
.record-content .video-embed {
  aspect-ratio: 16 / 9;
  width: 100%;
  height: auto;
}

3. Handle Extension-Injected Content

Bolt extensions use Twig events to inject content. Reserve space in your templates:

{# In your page template, reserve slots for extension output #}
<div class="extension-before-content" style="contain: layout;">
  {{ widgets('before_content') }}
</div>

<div class="main-content">
  {{ record.field('body') }}
</div>

<div class="extension-after-content" style="contain: layout;">
  {{ widgets('after_content') }}
</div>

For cookie consent or notification bars commonly added by extensions:

/* Extension-injected banners should overlay, not push */
.bolt-notification-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 9999;
}

4. Fix Embedded Content Dimensions

Rich text (HTML) fields in Bolt can contain iframes and embeds without dimensions. Apply CSS containment:

/* Responsive embed containers for content fields */
.record-content iframe {
  aspect-ratio: 16 / 9;
  width: 100%;
  height: auto;
  border: 0;
}

/* Twitter/social embeds */
.record-content .twitter-tweet,
.record-content .instagram-media {
  min-height: 400px;
  contain: layout;
}

/* Generic embed wrapper */
.embed-container {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
}
.embed-container iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

5. Preload Theme Fonts

{# In base.html.twig <head> #}
<link rel="preload" href="{{ asset('fonts/custom-font.woff2') }}"
      as="font" type="font/woff2" crossorigin>
@font-face {
  font-family: 'CustomFont';
  src: url('../fonts/custom-font.woff2') format('woff2');
  font-display: swap;
  size-adjust: 103%;
}

Measuring CLS on Bolt CMS

  1. Chrome DevTools Performance tab -- record page load, look for layout-shift entries in the Experience lane
  2. Symfony Profiler (dev mode) -- /_profiler shows which Twig blocks rendered and when, helping identify late-rendering components
  3. Test record types separately -- different content types (entries, pages, showcases) have different field combinations and CLS profiles
  4. Test first visit vs. cached -- thumbnail generation on first visit can cause different CLS than cached visits

Analytics Script Impact

Bolt gives full template control, so analytics CLS is entirely in your hands:

  • Place analytics at end of <body> to avoid blocking render
  • If using consent management, implement it as a position: fixed overlay
  • Heatmap tools (Hotjar, Lucky Orange) inject feedback widgets -- ensure they use fixed positioning