Cumulative Layout Shift (CLS) measures visual stability during page load. Static sites should have minimal CLS since content is pre-rendered, but images without dimensions, web fonts, and dynamic ads can cause layout shifts.
What is CLS?
CLS measures unexpected layout shifts during page load.
Thresholds:
- Good: < 0.1
- Needs Improvement: 0.1 - 0.25
- Poor: > 0.25
Common CLS Causes on Static Sites:
- Images without dimensions
- Web fonts loading (FOIT/FOUT)
- Ads, embeds, iframes
- Dynamically injected content
- Animations that cause reflow
Measuring CLS
PageSpeed Insights
- Go to PageSpeed Insights
- Enter your site URL
- View Cumulative Layout Shift metric
- Check Diagnostics for specific elements causing shifts
Lighthouse CLI
lighthouse https://yoursite.com --view
# Focus on layout shift elements
lighthouse https://yoursite.com --only-categories=performance --view
Web Vitals Extension
Install Web Vitals Chrome Extension to see CLS in real-time.
Common CLS Issues on Static Sites
1. Images Without Dimensions
Problem: Images load after layout, causing content shift.
Hugo Solution:
<!-- Bad - no dimensions -->
<img src="{{ .Src }}" alt="{{ .Alt }}">
<!-- Good - explicit dimensions -->
{{ $image := resources.Get .Src }}
<img
src="{{ $image.RelPermalink }}"
width="{{ $image.Width }}"
height="{{ $image.Height }}"
alt="{{ .Alt }}">
<!-- Better - responsive with aspect ratio -->
{{ $image := resources.Get .Src }}
<img
src="{{ $image.RelPermalink }}"
width="{{ $image.Width }}"
height="{{ $image.Height }}"
style="aspect-ratio: {{ $image.Width }} / {{ $image.Height }}; width: 100%; height: auto;"
alt="{{ .Alt }}">
Jekyll Solution:
<!-- Use imagemagick or sharp to get dimensions at build time -->
{% assign image = site.static_files | where: "path", include.src | first %}
<img
src="{{ include.src }}"
width="{{ image.width }}"
height="{{ image.height }}"
alt="{{ include.alt }}">
Gatsby Solution:
import { GatsbyImage } from 'gatsby-plugin-image';
// Automatic aspect ratio preservation
<GatsbyImage
image={data.heroImage.childImageSharp.gatsbyImageData}
alt="Hero"
/>
Next.js Solution:
import Image from 'next/image';
// Automatic aspect ratio with width/height
<Image
src="/hero.jpg"
width={1920}
height={1080}
alt="Hero"
/>
// Or use fill with container
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9' }}>
<Image
src="/hero.jpg"
fill
style={{ objectFit: 'cover' }}
alt="Hero"
/>
</div>
2. Web Font Loading
Problem: Fonts load and cause text to reflow (FOIT/FOUT).
Solution: font-display: swap
@font-face {
font-family: 'YourFont';
src: url('/fonts/your-font.woff2') format('woff2');
font-display: swap; /* Show fallback immediately */
}
Solution: Preload fonts
<link rel="preload" href="/fonts/primary.woff2" as="font" type="font/woff2" crossorigin>
Solution: Match fallback font metrics
/* Size fallback font to match web font */
body {
font-family: 'YourWebFont', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
}
/* Adjust fallback to match web font metrics */
@font-face {
font-family: 'YourWebFont Fallback';
src: local('Arial');
ascent-override: 105%;
descent-override: 35%;
line-gap-override: 10%;
size-adjust: 95%;
}
Solution: Use system fonts (no CLS)
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}
3. Ads and Embeds
Problem: Ads/embeds without reserved space cause layout shifts.
Solution: Reserve space for ads
<!-- Reserve space before ad loads -->
<div style="min-height: 250px;">
<div id="ad-container"></div>
</div>
Solution: Use aspect ratio for embeds
<!-- YouTube embed with aspect ratio -->
<div style="position: relative; padding-bottom: 56.25%; height: 0;">
<iframe
src="https://www.youtube.com/embed/VIDEO_ID"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
frameborder="0"
allowfullscreen>
</iframe>
</div>
Hugo shortcode for embeds:
<!-- layouts/shortcodes/youtube.html -->
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
<iframe
src="https://www.youtube.com/embed/{{ .Get 0 }}"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"
frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div>
Usage:
{{< youtube "dQw4w9WgXcQ" >}}
4. Dynamic Content Injection
Problem: Analytics scripts, chat widgets inject content that shifts layout.
Solution: Load below-the-fold
<!-- Place dynamic scripts at end of body -->
<body>
<!-- Static content -->
<!-- Dynamic scripts at end -->
<script src="analytics.js"></script>
<script src="chat-widget.js"></script>
</body>
Solution: Reserve space for dynamic elements
<!-- Chat widget placeholder -->
<div id="chat-widget" style="position: fixed; bottom: 20px; right: 20px; width: 60px; height: 60px;"></div>
Solution: Delay loading
// Load chat widget after page is stable
window.addEventListener('load', function() {
setTimeout(function() {
loadChatWidget();
}, 2000);
});
5. CSS Animations and Transitions
Problem: Animations that affect layout cause CLS.
Bad (causes CLS):
/* Animating width/height causes layout shift */
.element {
transition: width 0.3s;
}
.element:hover {
width: 200px;
}
Good (no CLS):
/* Animating transform doesn't cause layout shift */
.element {
transition: transform 0.3s;
}
.element:hover {
transform: scaleX(1.2);
}
Use transform instead of:
width,height→transform: scale()top,left→transform: translate()margin,padding→ Use fixed dimensions
6. Loading Indicators
Problem: Spinners/loaders that appear then disappear shift content.
Solution: Absolute positioning
<div style="position: relative; min-height: 200px;">
<!-- Content loads here -->
<div id="content"></div>
<!-- Loader doesn't affect layout -->
<div id="loader" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);">
<div class="spinner"></div>
</div>
</div>
Framework-Specific Fixes
Hugo CLS Prevention
Image dimensions from resources:
{{ $image := resources.Get "images/hero.jpg" }}
{{ $resized := $image.Resize "1920x jpg q85" }}
<img
src="{{ $resized.RelPermalink }}"
width="{{ $resized.Width }}"
height="{{ $resized.Height }}"
alt="Hero">
Responsive images with aspect ratio:
{{ $image := resources.Get .Src }}
{{ $small := $image.Resize "640x webp q85" }}
{{ $medium := $image.Resize "1024x webp q85" }}
{{ $large := $image.Resize "1920x webp q85" }}
<img
srcset="{{ $small.RelPermalink }} 640w,
{{ $medium.RelPermalink }} 1024w,
{{ $large.RelPermalink }} 1920w"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 1024px, 1920px"
src="{{ $large.RelPermalink }}"
width="{{ $large.Width }}"
height="{{ $large.Height }}"
style="width: 100%; height: auto;"
alt="{{ .Alt }}">
Gatsby CLS Prevention
Use gatsby-plugin-image:
import { GatsbyImage, getImage } from 'gatsby-plugin-image';
const BlogPost = ({ data }) => {
const image = getImage(data.markdownRemark.frontmatter.hero);
return (
<article>
{/* Automatic aspect ratio, no CLS */}
<GatsbyImage image={image} alt="Hero" />
<div dangerouslySetInnerHTML={{ __html: data.markdownRemark.html }} />
</article>
);
};
export const query = graphql`
query($slug: String!) {
markdownRemark(fields: { slug: { eq: $slug } }) {
html
frontmatter {
hero {
childImageSharp {
gatsbyImageData(width: 1920, placeholder: BLURRED)
}
}
}
}
}
`;
Next.js CLS Prevention
Use next/image:
import Image from 'next/image';
// Layout="responsive" prevents CLS
<Image
src="/hero.jpg"
width={1920}
height={1080}
layout="responsive"
alt="Hero"
/>
// Or use fill with container
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9' }}>
<Image
src="/hero.jpg"
fill
sizes="100vw"
alt="Hero"
/>
</div>
Reserve space for dynamic content:
const [content, setContent] = useState(null);
return (
<div style={{ minHeight: '500px' }}>
{content ? (
<div>{content}</div>
) : (
<div style={{ height: '500px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spinner />
</div>
)}
</div>
);
Tracking Script CLS Prevention
Analytics Scripts
Load in footer:
<!-- Hugo -->
{{ define "main" }}
{{ .Content }}
{{ end }}
{{ define "footer" }}
{{ partial "analytics.html" . }}
{{ end }}
Async loading:
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
GTM Container
Defer GTM loading:
// Load GTM after page is stable
window.addEventListener('load', function() {
(function(w,d,s,l,i){
w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});
var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';
j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXX');
});
Testing for CLS
Visual Regression Testing
# Install BackstopJS
npm install -g backstopjs
# Initialize
backstop init
# Configure backstop.json
{
"scenarios": [
{
"label": "Homepage",
"url": "https://yoursite.com",
"delay": 1000
}
]
}
# Create reference
backstop reference
# Test for changes
backstop test
Monitor Real Users
import {getCLS} from 'web-vitals';
getCLS(({name, value, id}) => {
// Send to GA4
gtag('event', name, {
event_category: 'Web Vitals',
value: Math.round(value * 1000),
event_label: id,
non_interaction: true,
});
});
Checklist for CLS Prevention
- All images have width/height - Prevents layout shift
- Use aspect-ratio CSS - Maintain layout during load
- Preload web fonts - Reduce FOUT/FOIT
- font-display: swap - Show fallback text immediately
- Reserve space for ads/embeds - Use min-height or aspect ratio
- Load analytics in footer - Don't inject above-the-fold
- Absolute position for loaders - Don't affect layout
- Use transform for animations - Not width/height/margin
- Static site = pre-rendered layout - Leverage build-time rendering
- Test with slow 3G - Exaggerate loading delays
Next Steps
- Fix LCP Issues - Largest Contentful Paint
- Debug Tracking Impact - Ensure tracking doesn't cause CLS
- Global CLS Guide - Universal concepts