Skip to main content
CodeRocket
Performancehighweb-vitals

Minimize cumulative layout shift

rule · cumulative-layout-shift

Cumulative Layout Shift measures unexpected movement of page content.

Code Examples

HTML
<!-- 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;"
>
TSX
// 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

ScoreRatingUser Experience
0–0.1GoodStable, no unexpected shifts
0.1–0.25Needs improvementNoticeable shifts
> 0.25PoorSignificant layout instability

Common Causes of Layout Shift

CauseImpactSolution
Images without dimensionsHighAlways set width/height
Ads and embedsHighReserve container space
Web fonts loadingMediumUse font-display: swap
Dynamic content injectionMediumUse placeholders
AnimationsLowUse transform/opacity

Reserve Space for Dynamic Content

TSX
// 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

CSS
/* 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;
}
TSX
// 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

CSS
/* 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

TSX
// 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

JavaScript
// 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