Minimize cumulative layout shift
Page maintains visual stability with a CLS score below 0.1, preventing unexpected content shifts during load.
- CLS measures visual stability—target score below 0.1
- Always set width/height on images and embeds
- Reserve space for ads, banners, and dynamic content
- Use transform animations instead of layout-changing properties
Rule Details
Cumulative Layout Shift measures unexpected movement of page content.
Code Examples
<!-- Always specify width and height -->
<img
src="hero.jpg"
alt="Hero image"
width="1200"
height="600"
loading="lazy"
>
<!-- Or use aspect-ratio CSS -->
<img
src="hero.jpg"
alt="Hero image"
style="aspect-ratio: 16/9; width: 100%; height: auto;"
>// React/Next.js with automatic dimensions
import Image from 'next/image'
function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority
// Dimensions prevent layout shift
/>
)
}Why It Matters
Layout shifts cause accidental clicks, reading disruption, and user frustration—a good CLS score ensures content stays where users expect it.
CLS Score Thresholds
| Score | Rating | User Experience |
|---|---|---|
| 0–0.1 | Good | Stable, no unexpected shifts |
| 0.1–0.25 | Needs improvement | Noticeable shifts |
| > 0.25 | Poor | Significant layout instability |
Common Causes of Layout Shift
| Cause | Impact | Solution |
|---|---|---|
| Images without dimensions | High | Always set width/height |
| Ads and embeds | High | Reserve container space |
| Web fonts loading | Medium | Use font-display: swap |
| Dynamic content injection | Medium | Use placeholders |
| Animations | Low | Use transform/opacity |
Reserve Space for Dynamic Content
// Reserve space for ads
function AdBanner() {
return (
<div
style={{
minHeight: '250px',
width: '300px',
backgroundColor: '#f0f0f0',
}}
>
{/* Ad loads here without causing shift */}
</div>
)
}
// Skeleton loading for content
function ArticleCard({ isLoading, article }) {
if (isLoading) {
return (
<div className="article-card">
<div className="skeleton" style={{ height: '200px' }} />
<div className="skeleton" style={{ height: '24px', width: '80%' }} />
<div className="skeleton" style={{ height: '16px', width: '60%' }} />
</div>
)
}
return (
<div className="article-card">
<img src={article.image} alt={article.title} width={300} height={200} />
<h2>{article.title}</h2>
<p>{article.excerpt}</p>
</div>
)
}Font Loading Strategy
/* Use font-display to prevent FOIT/FOUT shifts */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* Show fallback immediately, swap when loaded */
size-adjust: 100%; /* Match fallback font metrics */
ascent-override: 90%;
descent-override: 20%;
}
/* Use similar fallback font */
body {
font-family: 'CustomFont', Arial, sans-serif;
}// Next.js font optimization
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
// Prevents layout shift from font loading
})
export default function RootLayout({ children }) {
return (
<html className={inter.className}>
<body>{children}</body>
</html>
)
}Animations Without Layout Shift
/* Bad: Animating layout properties */
.bad-animation {
animation: slide-bad 0.3s ease-out;
}
@keyframes slide-bad {
from { margin-left: -100px; } /* Causes layout shift */
to { margin-left: 0; }
}
/* Good: Using transform */
.good-animation {
animation: slide-good 0.3s ease-out;
}
@keyframes slide-good {
from { transform: translateX(-100px); } /* No layout shift */
to { transform: translateX(0); }
}Avoid Content Injection Above Fold
// Bad: Inserting banner above content
function Page() {
const [showBanner, setShowBanner] = useState(false)
useEffect(() => {
// This causes layout shift when banner appears
setTimeout(() => setShowBanner(true), 1000)
}, [])
return (
<div>
{showBanner && <Banner />} {/* Pushes content down */}
<Content />
</div>
)
}
// Good: Reserve space for banner
function Page() {
const [showBanner, setShowBanner] = useState(false)
return (
<div>
<div style={{ minHeight: '60px' }}>
{showBanner && <Banner />}
</div>
<Content />
</div>
)
}Measuring CLS
// Using web-vitals library
import { onCLS } from 'web-vitals'
onCLS(metric => {
console.log('CLS:', metric.value)
// Report to analytics
if (metric.value > 0.1) {
console.warn('CLS exceeds threshold', metric.entries)
}
})
// Debug which elements caused shifts
new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue // Ignore user-triggered shifts
console.log('Layout shift:', {
value: entry.value,
sources: entry.sources?.map(s => ({
node: s.node,
currentRect: s.currentRect,
previousRect: s.previousRect
}))
})
}
}).observe({ type: 'layout-shift', buffered: true })Verification
Automated Checks
- Run Lighthouse—check CLS score in Performance
- Use Chrome DevTools Performance panel
- Check PageSpeed Insights for field data
Manual Checks
- Test on slow connections (reveals timing-based shifts)
- Monitor with Real User Monitoring
Use with AI
Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.
Check
Verify implementation
Measure CLS using Lighthouse or PageSpeed Insights. Verify score is below 0.1 for good visual stability.
Fix
Auto-fix issues
Reserve space for images/embeds with dimensions, use font-display strategies, and avoid injecting content above existing content.
Explain
Learn more
Explain how CLS measures visual stability and why unexpected layout shifts frustrate users and cause accidental clicks.
Review
Code review
Review the routes, assets, and loading behavior that affect Minimize cumulative layout shift. Flag exact files, requests, or rendering steps that add unnecessary network, CPU, or layout cost, and describe the measurement method used to confirm the issue.