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: swapcause 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
- Chrome DevTools Performance tab -- record page load, look for layout-shift entries in the Experience lane
- Symfony Profiler (dev mode) --
/_profilershows which Twig blocks rendered and when, helping identify late-rendering components - Test record types separately -- different content types (entries, pages, showcases) have different field combinations and CLS profiles
- 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: fixedoverlay - Heatmap tools (Hotjar, Lucky Orange) inject feedback widgets -- ensure they use fixed positioning