Cumulative Layout Shift (CLS) measures visual stability of your Contentful-powered site. Layout shifts frustrate users and hurt SEO rankings.
Target: CLS under 0.1 Good: Under 0.1 | Needs Improvement: 0.1-0.25 | Poor: Over 0.25
For general CLS concepts, see the global CLS guide.
Contentful-Specific CLS Issues
1. Images Without Dimensions
The most common CLS issue is images from Contentful loading without specified dimensions.
Problem: Images push content down as they load.
Diagnosis:
- Run PageSpeed Insights
- Look for "Image elements do not have explicit width and height"
- Use Chrome DevTools Performance tab to see layout shifts
Solutions:
A. Always Specify Image Dimensions
Contentful provides image dimensions in the API response:
// Extract dimensions from Contentful asset
export function ContentfulImage({ asset, alt }) {
const { url, details } = asset.fields.file
const { width, height } = details.image
return (
<img
src={url}
alt={alt || asset.fields.title}
width={width}
height={height}
loading="lazy"
/>
)
}
B. Use Aspect Ratio for Responsive Images
export function ResponsiveContentfulImage({ asset, alt }) {
const { url, details } = asset.fields.file
const { width, height } = details.image
const aspectRatio = height / width
return (
<div
style={{
position: 'relative',
paddingBottom: `${aspectRatio * 100}%`,
overflow: 'hidden',
}}
>
<img
src={url}
alt={alt || asset.fields.title}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
}}
loading="lazy"
/>
</div>
)
}
C. Modern CSS aspect-ratio
export function ModernAspectRatioImage({ asset, alt }) {
const { url, details } = asset.fields.file
const { width, height } = details.image
return (
<img
src={url}
alt={alt || asset.fields.title}
width={width}
height={height}
style={{
aspectRatio: `${width} / ${height}`,
width: '100%',
height: 'auto',
}}
loading="lazy"
/>
)
}
D. Next.js Image Component
Next.js automatically prevents CLS:
import Image from 'next/image'
export function ContentfulImage({ asset, alt }) {
const { url, details } = asset.fields.file
return (
<Image
src={`https:${url}`}
alt={alt || asset.fields.title}
width={details.image.width}
height={details.image.height}
placeholder="blur"
blurDataURL={getBlurDataURL(asset)}
/>
)
}
E. Gatsby Image Plugin
import { GatsbyImage } from 'gatsby-plugin-image'
export function ContentfulImage({ image }) {
return (
<GatsbyImage
image={image.gatsbyImageData}
alt={image.title}
/>
)
}
// In GraphQL query
query {
contentfulBlogPost(slug: { eq: $slug }) {
featuredImage {
title
gatsbyImageData(
width: 1200
placeholder: BLURRED
formats: [AUTO, WEBP]
)
}
}
}
2. Dynamic Content Loading
Content appearing after page load causes layout shifts.
Problem: Contentful content loaded client-side pushes existing content.
Diagnosis:
- Watch for content "popping in" after page load
- Check if skeleton/loading states match final content
- Measure CLS during content loading
Solutions:
A. Use Skeleton Screens with Correct Dimensions
'use client'
import { useEffect, useState } from 'react'
export function BlogPost({ slug }) {
const [post, setPost] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchContentfulPost(slug).then(data => {
setPost(data)
setLoading(false)
})
}, [slug])
if (loading) {
return (
<article>
{/* Skeleton matches final layout exactly */}
<div className="skeleton-title" style={{ height: '48px', marginBottom: '16px' }} />
<div className="skeleton-image" style={{ height: '400px', marginBottom: '24px' }} />
<div className="skeleton-text" style={{ height: '20px', marginBottom: '12px' }} />
<div className="skeleton-text" style={{ height: '20px', marginBottom: '12px' }} />
</article>
)
}
return (
<article>
<h1 style={{ height: '48px', marginBottom: '16px' }}>{post.fields.title}</h1>
<img src={post.fields.image.url} style={{ height: '400px', marginBottom: '24px' }} />
<p>{post.fields.content}</p>
</article>
)
}
B. Server-Side Render Content
Avoid client-side loading entirely:
Next.js (App Router):
// app/blog/[slug]/page.tsx
async function BlogPost({ params }) {
const post = await getContentfulPost(params.slug)
return (
<article>
<h1>{post.fields.title}</h1>
<ContentfulImage asset={post.fields.featuredImage} />
<RichTextContent content={post.fields.body} />
</article>
)
}
export default BlogPost
Next.js (Pages Router):
export async function getStaticProps({ params }) {
const post = await getContentfulPost(params.slug)
return {
props: { post },
revalidate: 3600,
}
}
export default function BlogPost({ post }) {
return (
<article>
<h1>{post.fields.title}</h1>
{/* Content already available, no loading state needed */}
</article>
)
}
C. Reserve Space for Dynamic Content
export function DynamicContent({ contentId }) {
const [content, setContent] = useState(null)
return (
<div
style={{
minHeight: '500px', // Reserve space
transition: 'min-height 0.3s ease',
}}
>
{content ? (
<ContentfulRichText content={content} />
) : (
<LoadingSkeleton height="500px" />
)}
</div>
)
}
3. Rich Text Rendering
Rich Text content can cause layout shifts during processing.
Problem: Rich Text content reflows as it renders.
Diagnosis:
- Check if Rich Text blocks shift during render
- Look for embedded content loading late
- Profile Rich Text processing time
Solutions:
A. Pre-render Rich Text on Server
// Server-side
import { documentToHtmlString } from '@contentful/rich-text-html-renderer'
export async function getStaticProps() {
const post = await getContentfulPost()
const bodyHtml = documentToHtmlString(post.fields.body, {
renderNode: {
[BLOCKS.EMBEDDED_ASSET]: (node) => {
const asset = node.data.target
return `
<img
src="${asset.fields.file.url}"
alt="${asset.fields.title}"
width="${asset.fields.file.details.image.width}"
height="${asset.fields.file.details.image.height}"
/>
`
},
},
})
return {
props: {
post,
bodyHtml,
},
}
}
B. Reserve Space for Embedded Content
import { documentToReactComponents } from '@contentful/rich-text-react-renderer'
import { BLOCKS } from '@contentful/rich-text-types'
const renderOptions = {
renderNode: {
[BLOCKS.EMBEDDED_ASSET]: (node) => {
const asset = node.data.target
const { width, height } = asset.fields.file.details.image
return (
<div
style={{
aspectRatio: `${width} / ${height}`,
width: '100%',
}}
>
<img
src={asset.fields.file.url}
alt={asset.fields.title}
width={width}
height={height}
style={{ width: '100%', height: 'auto' }}
/>
</div>
)
},
[BLOCKS.EMBEDDED_ENTRY]: (node) => {
// Reserve space for embedded entries
return (
<div style={{ minHeight: '200px' }}>
<EmbeddedContent entry={node.data.target} />
</div>
)
},
},
}
export function RichTextContent({ content }) {
return <>{documentToReactComponents(content, renderOptions)}</>
}
4. Web Fonts Loading
Custom fonts cause layout shifts if not handled correctly.
Problem: Text reflowing when web fonts load (FOUT - Flash of Unstyled Text).
Diagnosis:
- Watch for text shifting when fonts load
- Check for size differences between fallback and web fonts
- Use font-display settings
Solutions:
A. Use font-display: optional
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom-font.woff2') format('woff2');
font-display: optional; /* Prevents layout shift */
font-weight: 400;
}
B. Match Fallback Font Metrics
Use a fallback font that closely matches your web font:
body {
font-family: 'CustomFont', system-ui, -apple-system, 'Segoe UI', sans-serif;
}
Or use @font-face size-adjust:
@font-face {
font-family: 'CustomFontFallback';
src: local('Arial');
size-adjust: 95%; /* Match custom font size */
ascent-override: 95%;
descent-override: 25%;
}
body {
font-family: 'CustomFont', 'CustomFontFallback', sans-serif;
}
C. Preload Critical Fonts
// Next.js layout
export default function RootLayout({ children }) {
return (
<html>
<head>
<link
rel="preload"
href="/fonts/custom-font.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
</head>
<body>{children}</body>
</html>
)
}
5. Embedded Media from Contentful
Videos and iframes from Contentful can cause shifts.
Problem: Embedded content loading without reserved space.
Diagnosis:
- Check for YouTube/Vimeo embeds from Contentful
- Look for iframes without dimensions
- Check embedded assets in Rich Text
Solutions:
A. Aspect Ratio Containers for Videos
export function EmbeddedVideo({ url, aspectRatio = 16 / 9 }) {
return (
<div
style={{
position: 'relative',
paddingBottom: `${(1 / aspectRatio) * 100}%`,
height: 0,
overflow: 'hidden',
}}
>
<iframe
src={url}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
border: 0,
}}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
)
}
B. Handle Embedded Entries in Rich Text
const renderOptions = {
renderNode: {
[BLOCKS.EMBEDDED_ENTRY]: (node) => {
const entry = node.data.target
// Reserve space based on content type
if (entry.sys.contentType.sys.id === 'videoEmbed') {
return (
<div style={{ aspectRatio: '16 / 9' }}>
<EmbeddedVideo url={entry.fields.url} />
</div>
)
}
if (entry.sys.contentType.sys.id === 'codeBlock') {
return (
<pre style={{ minHeight: '100px' }}>
<code>{entry.fields.code}</code>
</pre>
)
}
return null
},
},
}
6. Dynamic Banners and Popups
Contentful-driven banners or popups can shift content.
Problem: Announcement bars or modals pushing content down.
Diagnosis:
- Check for Contentful-driven notification banners
- Look for popups appearing after page load
- Measure CLS when banner appears
Solutions:
A. Reserve Space for Banners
export function AnnouncementBanner() {
const [banner, setBanner] = useState(null)
useEffect(() => {
fetchContentfulBanner().then(setBanner)
}, [])
return (
<div
style={{
height: banner ? 'auto' : '0',
minHeight: banner ? '50px' : '0',
overflow: 'hidden',
transition: 'height 0.3s ease',
}}
>
{banner && (
<div style={{ padding: '16px' }}>
{banner.fields.message}
</div>
)}
</div>
)
}
B. Use position: fixed for Overlays
export function ContentfulPopup({ popup }) {
if (!popup) return null
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1000,
// Doesn't affect layout
}}
>
<div className="popup-content">
{popup.fields.content}
</div>
</div>
)
}
7. Loading States and Transitions
Poor loading states can cause CLS.
Problem: Content appearing/disappearing causes shifts.
Diagnosis:
- Watch transitions between loading and loaded states
- Check if loading skeleton matches content
- Measure CLS during transitions
Solutions:
A. Match Loading State to Content
export function BlogPostSkeleton() {
return (
<article>
{/* Exact dimensions matching final content */}
<div className="skeleton-title" style={{ height: '48px', width: '80%', marginBottom: '16px' }} />
<div className="skeleton-meta" style={{ height: '20px', width: '40%', marginBottom: '24px' }} />
<div className="skeleton-image" style={{ height: '400px', width: '100%', marginBottom: '24px' }} />
<div className="skeleton-paragraph" style={{ height: '16px', width: '100%', marginBottom: '8px' }} />
<div className="skeleton-paragraph" style={{ height: '16px', width: '95%', marginBottom: '8px' }} />
<div className="skeleton-paragraph" style={{ height: '16px', width: '90%' }} />
</article>
)
}
B. Fade Transitions Instead of Layout Changes
export function ContentfulContent({ content }) {
const [isLoading, setIsLoading] = useState(true)
return (
<div style={{ position: 'relative', minHeight: '500px' }}>
<div
style={{
opacity: isLoading ? 1 : 0,
transition: 'opacity 0.3s',
position: 'absolute',
top: 0,
left: 0,
width: '100%',
}}
>
<LoadingSkeleton />
</div>
<div
style={{
opacity: isLoading ? 0 : 1,
transition: 'opacity 0.3s',
}}
>
{content && <RichTextContent content={content} />}
</div>
</div>
)
}
Framework-Specific Solutions
Next.js
Use next/image:
import Image from 'next/image'
export function ContentfulImage({ asset }) {
return (
<Image
src={`https:${asset.fields.file.url}`}
alt={asset.fields.title}
width={asset.fields.file.details.image.width}
height={asset.fields.file.details.image.height}
// Prevents CLS automatically
/>
)
}
Streaming with Suspense:
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<ContentfulContent />
</Suspense>
)
}
Gatsby
import { GatsbyImage } from 'gatsby-plugin-image'
export function ContentfulImage({ image }) {
return (
<GatsbyImage
image={image.gatsbyImageData}
alt={image.title}
// Automatically prevents CLS
/>
)
}
GraphQL for Image Data:
query {
contentfulBlogPost {
featuredImage {
gatsbyImageData(
width: 1200
placeholder: BLURRED
)
title
file {
url
details {
image {
width
height
}
}
}
}
}
}
React (General)
Custom Hook for Contentful Images:
export function useContentfulImage(asset: any) {
const { url, details } = asset.fields.file
const { width, height } = details.image
return {
src: url,
width,
height,
aspectRatio: width / height,
}
}
// Usage
export function ImageComponent({ asset }) {
const image = useContentfulImage(asset)
return (
<img
src={image.src}
width={image.width}
height={image.height}
style={{ aspectRatio: image.aspectRatio }}
alt={asset.fields.title}
/>
)
}
Testing & Monitoring
Measure CLS
Chrome DevTools:
- Open DevTools (F12)
- Go to Performance tab
- Record page load
- Look for red bars labeled "Layout Shift"
- Click to see which elements shifted
Web Vitals Library:
import { getCLS } from 'web-vitals'
getCLS((metric) => {
console.log('CLS:', metric.value)
// Send to analytics
if (window.gtag) {
window.gtag('event', 'web_vitals', {
event_category: 'Web Vitals',
event_label: 'CLS',
value: Math.round(metric.value * 1000),
non_interaction: true,
})
}
})
PageSpeed Insights:
- Test your URL at PageSpeed Insights
- Check both Lab and Field Data
- Review specific layout shift elements
Debug Layout Shifts
Visual Indicator:
// Highlight layout shifts (development only)
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue
entry.sources.forEach(source => {
const element = source.node
element.style.outline = '2px solid red'
console.log('Layout shift:', element, entry.value)
})
}
})
observer.observe({ type: 'layout-shift', buffered: true })
Quick Wins Checklist
Priority fixes for immediate CLS improvements:
- Add width/height to all Contentful images
- Use aspect-ratio CSS for responsive images
- Match skeleton screens to final content dimensions
- Use font-display: optional for web fonts
- Reserve space for embedded videos/iframes
- Pre-render Rich Text on server when possible
- Use position: fixed for banners/popups
- Implement proper loading states
- Use Next.js Image or gatsby-plugin-image
- Test with Chrome DevTools Performance tab
Advanced Optimizations
CSS contain Property
Help browser isolate layout calculations:
.contentful-content {
contain: layout style paint;
}
Content Visibility
Improve off-screen content rendering:
.below-fold-content {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Estimated height */
}
Intersection Observer for Images
Load images only when visible:
export function LazyContentfulImage({ asset }) {
const [isVisible, setIsVisible] = useState(false)
const imgRef = useRef(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.disconnect()
}
},
{ rootMargin: '50px' }
)
if (imgRef.current) {
observer.observe(imgRef.current)
}
return () => observer.disconnect()
}, [])
return (
<div
ref={imgRef}
style={{
aspectRatio: `${asset.fields.file.details.image.width} / ${asset.fields.file.details.image.height}`,
backgroundColor: '#f0f0f0',
}}
>
{isVisible && (
<img
src={asset.fields.file.url}
alt={asset.fields.title}
width={asset.fields.file.details.image.width}
height={asset.fields.file.details.image.height}
/>
)}
</div>
)
}
Next Steps
For general CLS optimization strategies, see CLS Optimization Guide.