perf hacks
Core Web VitalsCLS

CLS - Cumulative Layout Shift

Deep dive into eliminating unexpected layout shifts for visual stability

Cumulative Layout Shift (CLS) measures the visual stability of a page. It quantifies how much visible content shifts unexpectedly during the page lifecycle.

Understanding CLS

Score Thresholds

ScoreRatingUser Perception
≤ 0.1GoodStable, predictable
0.1 - 0.25Needs ImprovementSome jank
> 0.25PoorFrustrating, causes misclicks

How CLS is Calculated

Layout Shift Score = Impact Fraction × Distance Fraction
  • Impact Fraction: Percentage of viewport affected
  • Distance Fraction: How far elements moved (as % of viewport)

Layout shifts within 500ms of user interaction are excluded from CLS, as users expect content to move when they interact.

Common CLS Causes in Next.js

1. Images Without Dimensions

// ❌ Bad: No dimensions causes layout shift
<img src="/photo.jpg" alt="Photo" />

// ✅ Good: Explicit dimensions
<Image
  src="/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
/>

// ✅ Also good: Aspect ratio with fill
<div className="relative aspect-video">
  <Image
    src="/photo.jpg"
    alt="Photo"
    fill
    className="object-cover"
  />
</div>

2. Web Fonts Causing FOUT

// ❌ Bad: Font flash causes text to reflow
@import url('https://fonts.googleapis.com/css2?family=Roboto');

// ✅ Good: Use next/font with proper fallbacks
import { Roboto } from 'next/font/google';

const roboto = Roboto({
  subsets: ['latin'],
  weight: ['400', '700'],
  display: 'swap',
  fallback: ['system-ui', 'arial'],
  adjustFontFallback: true,
});

3. Dynamic Content Without Placeholders

// ❌ Bad: Content pops in, shifting everything
function AdBanner() {
  const [ad, setAd] = useState(null);

  useEffect(() => {
    loadAd().then(setAd);
  }, []);

  if (!ad) return null; // Nothing rendered!

  return <div>{ad.content}</div>;
}

// ✅ Good: Reserve space with skeleton
function AdBanner() {
  const [ad, setAd] = useState(null);

  useEffect(() => {
    loadAd().then(setAd);
  }, []);

  return (
    <div className="h-[250px] w-full"> {/* Reserved height */}
      {ad ? (
        <div>{ad.content}</div>
      ) : (
        <Skeleton className="h-full w-full" />
      )}
    </div>
  );
}

4. Injected Content (Ads, Embeds)

// ❌ Bad: Third-party content pushes content down
<div id="ad-container" /> {/* Unknown size */}

// ✅ Good: Container with min-height
<div
  id="ad-container"
  style={{ minHeight: '250px', contain: 'layout' }}
/>

Optimization Strategies

Reserve Space for Dynamic Content

components/DynamicContent.tsx
interface PlaceholderProps {
  expectedHeight: number;
  children: React.ReactNode;
  isLoaded: boolean;
}

function ContentWithPlaceholder({
  expectedHeight,
  children,
  isLoaded
}: PlaceholderProps) {
  return (
    <div
      style={{
        minHeight: isLoaded ? 'auto' : expectedHeight,
        contain: 'layout',
      }}
    >
      {children}
    </div>
  );
}

// Usage
function Newsletter() {
  const [isLoaded, setIsLoaded] = useState(false);

  return (
    <ContentWithPlaceholder expectedHeight={200} isLoaded={isLoaded}>
      <NewsletterForm onLoad={() => setIsLoaded(true)} />
    </ContentWithPlaceholder>
  );
}

Use CSS Aspect Ratio

/* Reserve space for different aspect ratios */
.video-container {
  aspect-ratio: 16 / 9;
  width: 100%;
}

.square-image {
  aspect-ratio: 1 / 1;
  width: 100%;
}

.portrait-image {
  aspect-ratio: 3 / 4;
  width: 100%;
}
// In component
function VideoEmbed({ src }) {
  return (
    <div className="aspect-video w-full bg-gray-100">
      <iframe
        src={src}
        className="h-full w-full"
        loading="lazy"
      />
    </div>
  );
}

Optimize Font Loading

app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';
import localFont from 'next/font/local';

// Google Fonts with fallback adjustment
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
  adjustFontFallback: true,
});

// Local fonts with explicit fallback metrics
const myFont = localFont({
  src: './fonts/MyFont.woff2',
  display: 'swap',
  fallback: ['Arial', 'sans-serif'],
  adjustFontFallback: 'Arial', // Base metrics on Arial
});

export default function Layout({ children }) {
  return (
    <html className={`${inter.variable} ${myFont.className}`}>
      <body>{children}</body>
    </html>
  );
}

Handle Loading States Properly

import { Suspense } from 'react';

// Skeleton with matching dimensions
function ProductCardSkeleton() {
  return (
    <div className="h-[300px] w-full animate-pulse">
      <div className="h-[200px] bg-gray-200 rounded-t-lg" />
      <div className="p-4 space-y-2">
        <div className="h-4 bg-gray-200 rounded w-3/4" />
        <div className="h-4 bg-gray-200 rounded w-1/2" />
      </div>
    </div>
  );
}

// Main component
function ProductGrid() {
  return (
    <div className="grid grid-cols-3 gap-4">
      <Suspense fallback={<ProductCardSkeleton />}>
        <ProductCard />
      </Suspense>
    </div>
  );
}

Use CSS Containment

/* Prevent layout shifts from affecting other elements */
.sidebar {
  contain: layout;
}

.ad-slot {
  contain: layout size;
  height: 250px;
}

.dynamic-widget {
  contain: layout style;
}
// In component
<aside
  className="sidebar"
  style={{ contain: 'layout' }}
>
  <DynamicWidget />
</aside>

Diagnosing CLS Issues

Chrome DevTools

Enable Layout Shift Regions DevTools → More tools → Rendering → Layout Shift Regions

Record Performance Performance tab → Record → Reload page

Find layout shifts Look in the Experience row for red "Layout Shift" entries

Identify culprits Click on shift to see which elements moved

Programmatic Measurement

import { onCLS, type CLSMetric } from 'web-vitals';

export function measureCLS() {
  onCLS((metric: CLSMetric) => {
    console.log('CLS:', metric.value);

    // Log each layout shift source
    metric.entries.forEach((entry) => {
      entry.sources?.forEach((source) => {
        console.log('Shifted element:', source.node);
        console.log('Previous rect:', source.previousRect);
        console.log('Current rect:', source.currentRect);
      });
    });
  });
}

Layout Instability API

// Monitor layout shifts in real-time
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      console.log('Layout shift:', entry.value);
      entry.sources?.forEach(source => {
        console.log('Element:', source.node);
      });
    }
  }
});

observer.observe({ type: 'layout-shift', buffered: true });

Common Patterns & Fixes

Toast Notifications

// ❌ Bad: Toast pushes content
function Toast({ message }) {
  return (
    <div className="fixed top-0 left-0 right-0 p-4 bg-blue-500">
      {message}
    </div>
  );
}

// ✅ Good: Toast overlays, doesn't shift
function Toast({ message }) {
  return (
    <div className="fixed top-4 right-4 p-4 bg-blue-500 z-50 shadow-lg">
      {message}
    </div>
  );
}
// ❌ Bad: Banner at top pushes everything down
<header>
  <CookieBanner />  {/* Appears after load */}
  <Navigation />
</header>

// ✅ Good: Banner at bottom or overlay
<>
  <header>
    <Navigation />
  </header>
  <CookieBanner className="fixed bottom-0 left-0 right-0" />
</>

Collapsible Content

// ❌ Bad: Content appears/disappears suddenly
{isOpen && <ExpandedContent />}

// ✅ Good: Animate with max-height
<div
  className={`overflow-hidden transition-[max-height] duration-300
    ${isOpen ? 'max-h-[500px]' : 'max-h-0'}`}
>
  <ExpandedContent />
</div>

CLS Optimization Checklist

Images & Media

Set width/height on all images Explicit dimensions reserve space before images load.

Use aspect-ratio for responsive images Maintain proportions while allowing flexible sizing.

Add placeholder/skeleton during load Visual placeholder prevents sudden content appearance.

Use fill with container dimensions Container controls size; image fills the space.

Preload LCP images Faster loading reduces time window for shifts.

Fonts

Use next/font for all fonts Automatic optimization and zero layout shift.

Set display: swap Show fallback immediately, swap when font loads.

Enable adjustFontFallback Matches fallback metrics to reduce reflow.

Use font subsetting Smaller font files load faster.

Preload critical font files Start font download early in page load.

Dynamic Content

Reserve space for ads/embeds Use min-height on containers for third-party content.

Use min-height on containers Prevent collapse when content is loading.

Show skeletons matching final size Placeholder should match the loaded component dimensions.

Use CSS containment Isolate layout calculations to specific sections.

Avoid inserting content above existing Add new content below or use fixed positioning.

Animations

Use transform instead of layout properties Transforms don't trigger layout shifts.

Avoid animating width/height These properties cause layout recalculations.

Use will-change sparingly Hint to browser, but overuse hurts performance.

Prefer CSS transitions over JS CSS animations run on compositor thread.

Real-World Impact

FixCLS Improvement
Adding image dimensions-0.1 to -0.3
Font optimization-0.05 to -0.15
Skeleton placeholders-0.1 to -0.2
CSS containment-0.05 to -0.1
Fixed position for banners-0.1 to -0.2

On this page