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
| Score | Rating | User Perception |
|---|---|---|
| ≤ 0.1 | Good | Stable, predictable |
| 0.1 - 0.25 | Needs Improvement | Some jank |
| > 0.25 | Poor | Frustrating, 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
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
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>
);
}Cookie Banners
// ❌ 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
| Fix | CLS 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 |