PatternsVisual Performance
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 loadCLS 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 visitsMeasuring 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 elementsLayout Shift Regions
DevTools → Rendering → Layout Shift Regions
Blue overlay shows elements that shiftedExperience Panel
DevTools → Experience tab
Shows all layout shifts with:
- Time
- Score
- Affected elementsTesting 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
- Always Set Dimensions: Images, videos, iframes
- Reserve Space: Ads, embeds, dynamic content
- Skeleton Screens: Show placeholders
- Preload Fonts: With matching fallback metrics
- Transform Over Layout: Animate transform/opacity
- Fixed/Absolute: For overlays that don't shift content
- min-height: For dynamic containers
- Avoid Above-Fold Injection: Don't insert content that shifts viewport
- Test on Real Devices: Mobile often has worse CLS
- 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!