Front-end Engineering Lab

Layout Shift Prevention

Deep dive into preventing Cumulative Layout Shift (CLS) for visual stability

Layout Shift Prevention

Layout shifts occur when visible elements change position unexpectedly. They're frustrating for users and hurt your CLS score. This guide covers every technique to prevent them.

Understanding Layout Shifts

What Causes Layout Shifts?

1. Images without dimensions
2. Ads/embeds/iframes without reserved space
3. Dynamically injected content
4. Web fonts causing FOIT/FOUT
5. Animations that trigger layout
6. CSS that changes dimensions on load

CLS Scoring

CLS = Impact Fraction × Distance Fraction

Good:      < 0.1
Needs Work: 0.1 - 0.25
Poor:      > 0.25

Target: < 0.1 for 75% of page visits

Measuring Layout Shifts

Layout Shift API

let clsScore = 0;

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach((entry: any) => {
    // Only count shifts without recent user input
    if (!entry.hadRecentInput) {
      clsScore += entry.value;
      
      console.log('Layout shift detected:', {
        value: entry.value,
        sources: entry.sources,
        startTime: entry.startTime,
      });
      
      // Log affected elements
      entry.sources?.forEach((source: any) => {
        console.log('Shifted element:', source.node);
      });
    }
  });
});

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

// Report CLS on page unload
window.addEventListener('beforeunload', () => {
  console.log('Final CLS score:', clsScore);
});

web-vitals Library

import { onCLS } from 'web-vitals';

onCLS((metric) => {
  console.log('CLS:', metric.value);
  console.log('Rating:', metric.rating); // 'good', 'needs-improvement', 'poor'
  
  // Send to analytics
  gtag('event', 'CLS', {
    value: Math.round(metric.value * 1000),
    event_category: 'Web Vitals',
    event_label: metric.id,
  });
});

Visualize Layout Shifts

// Highlight elements that shift
const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach((entry: any) => {
    entry.sources?.forEach((source: any) => {
      if (source.node) {
        // Add visual indicator
        source.node.style.outline = '3px solid red';
        setTimeout(() => {
          source.node.style.outline = '';
        }, 2000);
      }
    });
  });
});

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

Prevention Strategies

1. Always Set Image Dimensions

// ❌ BAD: No dimensions (causes layout shift)
<img src="/image.jpg" alt="Product" />

// ✅ GOOD: Explicit dimensions
<img 
  src="/image.jpg" 
  alt="Product"
  width="800"
  height="600"
/>

// ✅ GOOD: aspect-ratio with CSS
<img 
  src="/image.jpg" 
  alt="Product"
  style={{ aspectRatio: '4 / 3', width: '100%' }}
/>

// ✅ GOOD: Next.js Image
import Image from 'next/image';

<Image
  src="/image.jpg"
  alt="Product"
  width={800}
  height={600}
/>

2. Reserve Space for Dynamic Content

// ❌ BAD: Banner appears and shifts content
function App() {
  const [showBanner, setShowBanner] = useState(false);
  
  useEffect(() => {
    setTimeout(() => setShowBanner(true), 2000);
  }, []);
  
  return (
    <>
      {showBanner && <Banner />}  {/* Shifts everything down */}
      <Content />
    </>
  );
}

// ✅ GOOD: Fixed position (doesn't shift)
function App() {
  const [showBanner, setShowBanner] = useState(false);
  
  return (
    <>
      <Banner 
        visible={showBanner}
        style={{ position: 'fixed', top: 0, zIndex: 1000 }}
      />
      <Content style={{ marginTop: showBanner ? '60px' : 0 }} />
    </>
  );
}

// ✅ GOOD: Reserve space upfront
function App() {
  return (
    <>
      <div style={{ minHeight: '60px' }}>
        {showBanner && <Banner />}
      </div>
      <Content />
    </>
  );
}

3. Reserve Space for Ads/Embeds

// Reserve space for ad unit
<div 
  className="ad-container"
  style={{ 
    minHeight: '250px',
    minWidth: '300px',
    backgroundColor: '#f0f0f0',
  }}
>
  <AdComponent />
</div>

// Reserve space for YouTube embed
<div style={{ aspectRatio: '16 / 9' }}>
  <iframe
    src="https://www.youtube.com/embed/VIDEO_ID"
    width="100%"
    height="100%"
    frameBorder="0"
    allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
    allowFullScreen
  />
</div>

// Reserve space for Twitter embed
<div style={{ minHeight: '500px' }}>
  <blockquote className="twitter-tweet">
    {/* Tweet content */}
  </blockquote>
</div>

4. Preload Fonts with Fallback Metrics

<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>
/* Match fallback font metrics to custom font */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}

@font-face {
  font-family: 'Inter Fallback';
  src: local('Arial');
  size-adjust: 105.2%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter Fallback', sans-serif;
}

5. Use font-display: optional

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: optional;  /* Only use if cached, else stick with fallback */
}

6. Avoid Inserting Content Above Viewport

// ❌ BAD: Injects content above, shifts everything
function BlogPost() {
  const [relatedPosts, setRelatedPosts] = useState([]);
  
  useEffect(() => {
    fetchRelatedPosts().then(setRelatedPosts);
  }, []);
  
  return (
    <>
      {relatedPosts.length > 0 && (
        <RelatedPosts posts={relatedPosts} />  {/* Shifts content down */}
      )}
      <Article />
    </>
  );
}

// ✅ GOOD: Append below or use skeleton
function BlogPost() {
  return (
    <>
      <Article />
      <Suspense fallback={<RelatedPostsSkeleton />}>
        <RelatedPosts />
      </Suspense>
    </>
  );
}

7. Use CSS Transform Instead of Layout Properties

/* ❌ BAD: Triggers layout */
.box {
  transition: width 0.3s, height 0.3s, top 0.3s, left 0.3s;
}
.box:hover {
  width: 200px;
  height: 200px;
  top: 50px;
  left: 50px;
}

/* ✅ GOOD: No layout (uses compositor) */
.box {
  transition: transform 0.3s, opacity 0.3s;
}
.box:hover {
  transform: scale(1.2) translate(10px, 10px);
}

8. Skeleton Screens

// Show skeleton while loading
function ProductCard({ productId }: Props) {
  const { data: product, isLoading } = useQuery(['product', productId], fetchProduct);

  if (isLoading) {
    return (
      <div className="product-card">
        <div className="skeleton-image" style={{ aspectRatio: '1', width: '100%' }} />
        <div className="skeleton-text" style={{ height: '24px', width: '80%' }} />
        <div className="skeleton-text" style={{ height: '16px', width: '60%' }} />
      </div>
    );
  }

  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
    </div>
  );
}

9. Avoid Animating Layout Properties

// Properties that trigger layout (avoid animating):
- width, height
- padding, margin
- top, right, bottom, left
- border-width
- font-size

// Properties safe to animate (compositor-only):
- transform (translate, scale, rotate)
- opacity
- filter
/* ❌ BAD: Animates layout property */
@keyframes slideIn {
  from { left: -100px; }
  to { left: 0; }
}

/* ✅ GOOD: Animates transform */
@keyframes slideIn {
  from { transform: translateX(-100px); }
  to { transform: translateX(0); }
}

10. Set min-height on Dynamic Containers

// Prevent collapse while loading
<div style={{ minHeight: '400px' }}>
  {isLoading ? <Spinner /> : <Content data={data} />}
</div>

// Table with minimum height
<table style={{ minHeight: '600px' }}>
  {isLoading ? (
    <SkeletonRows count={10} />
  ) : (
    <tbody>
      {rows.map(row => <Row key={row.id} data={row} />)}
    </tbody>
  )}
</table>

Advanced Techniques

Intersection Observer for Late-Loading Content

// Only load when visible (prevents shifts above viewport)
function LazySection() {
  const [visible, setVisible] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setVisible(true);
          observer.disconnect();
        }
      },
      { rootMargin: '100px' }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <div ref={ref} style={{ minHeight: '400px' }}>
      {visible ? <ExpensiveComponent /> : <Skeleton />}
    </div>
  );
}

Content-Visibility for Off-Screen Content

/* Skip rendering off-screen content */
.list-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 400px;  /* Estimated height */
}

Use will-change Sparingly

/* Hint browser about upcoming changes */
.animated-element {
  will-change: transform;
}

/* Remove after animation */
.animated-element.animated {
  will-change: auto;
}

Debugging Layout Shifts

Chrome DevTools

1. Open DevTools
2. Performance tab
3. Check "Web Vitals" checkbox
4. Record page load
5. Look for red "Layout Shift" bars
6. Click to see affected elements

Layout Shift Regions

DevTools → Rendering → Layout Shift Regions
Blue overlay shows elements that shifted

Experience Panel

DevTools → Experience tab
Shows all layout shifts with:
- Time
- Score
- Affected elements

Testing Layout Shifts

// Automated testing
describe('Layout Shifts', () => {
  it('should have CLS < 0.1', async () => {
    const page = await browser.newPage();
    
    let clsScore = 0;
    await page.evaluateOnNewDocument(() => {
      new PerformanceObserver((list) => {
        list.getEntries().forEach((entry: any) => {
          if (!entry.hadRecentInput) {
            (window as any).clsScore = ((window as any).clsScore || 0) + entry.value;
          }
        });
      }).observe({ type: 'layout-shift', buffered: true });
    });
    
    await page.goto('https://example.com');
    await page.waitForLoadState('networkidle');
    
    const cls = await page.evaluate(() => (window as any).clsScore || 0);
    expect(cls).toBeLessThan(0.1);
  });
});

Best Practices

  1. Always Set Dimensions: Images, videos, iframes
  2. Reserve Space: Ads, embeds, dynamic content
  3. Skeleton Screens: Show placeholders
  4. Preload Fonts: With matching fallback metrics
  5. Transform Over Layout: Animate transform/opacity
  6. Fixed/Absolute: For overlays that don't shift content
  7. min-height: For dynamic containers
  8. Avoid Above-Fold Injection: Don't insert content that shifts viewport
  9. Test on Real Devices: Mobile often has worse CLS
  10. Monitor in Production: Track field CLS data

Common Pitfalls

No image dimensions: Main cause of CLS
Always set width/height

Late-loading fonts: Text jumps
Preload + size-adjust fallback

Dynamic banners: Shift content down
Fixed position or reserve space

Ads without dimensions: Unpredictable shifts
Reserve minimum space

Animating width/height: Causes reflow
Animate transform instead

Layout shifts ruin user experience—eliminate them completely for a perfect CLS score!

On this page