Optimizing CLS (Cumulative Layout Shift)
CLS — total score of element shifts during page load. When text or buttons jump when user clicks — that's CLS. Goal: ≤ 0.1.
Calculation formula
CLS = Σ (impact fraction × distance fraction) for each unexpected shift.
Impact fraction — part of viewport occupied by shifted elements. Distance fraction — shift distance relative to viewport.
Diagnosis
DevTools → Performance → record → find "Layout Shift" marks (purple). Or:
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
console.log('Layout shift:', entry.value, entry.sources);
}
}
}).observe({ type: 'layout-shift', buffered: true });
entry.sources shows concrete DOM elements that shifted.
Images without dimensions — most common cause
<!-- Bad: browser doesn't know size until load -->
<img src="product.webp" alt="...">
<!-- Good: width and height attributes -->
<img src="product.webp" width="800" height="600" alt="...">
CSS for responsiveness with aspect preservation:
img {
max-width: 100%;
height: auto;
}
/* Or via aspect-ratio */
.product-image {
aspect-ratio: 4 / 3;
width: 100%;
object-fit: cover;
}
Fonts and FOUT
Font Swap causes shift when main font loads and replaces system (fallback).
/* Method 1: font-display: optional — don't show swap at all */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: optional;
}
/* Method 2: size-adjust — match fallback to main font size */
@font-face {
font-family: 'InterFallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'InterFallback', sans-serif;
}
Tool for picking size-adjust: fontaine package or next/font (automatically picks size-adjust for Google Fonts).
Dynamic content
/* Banner/ad block: reserve space before load */
.ad-banner {
min-height: 90px; /* standard leaderboard */
background: #f5f5f5; /* placeholder */
}
/* For responsive blocks */
.hero-section {
aspect-ratio: 16 / 9;
max-height: 600px;
}
Skeletons instead of empty space
Don't leave space empty — show skeleton placeholder with correct dimensions:
function ProductCardSkeleton() {
return (
<div className="product-card-skeleton">
<div className="skeleton-image" style={{ aspectRatio: '1 / 1' }} />
<div className="skeleton-title" style={{ height: '1.5rem', width: '80%' }} />
<div className="skeleton-price" style={{ height: '1.25rem', width: '40%' }} />
</div>
);
}
.skeleton-image, .skeleton-title, .skeleton-price {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Animations
Animations changing layout properties (width, height, top, left, margin, padding) cause CLS. Use only transform and opacity:
/* Bad — causes layout reflow */
.modal { animation: slideDown 300ms; }
@keyframes slideDown { from { height: 0; } to { height: 400px; } }
/* Good — only transform */
.modal { animation: slideDown 300ms; }
@keyframes slideDown {
from { transform: translateY(-100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
contain: layout
For isolated components — contain: layout or contain: strict prevents internal changes from affecting external layout:
.widget-container {
contain: layout;
}
Fixed-positioned header
Sticky/fixed header appearing shouldn't shift content. Padding on body:
body {
padding-top: var(--header-height, 64px);
}
.header {
position: fixed;
top: 0;
height: var(--header-height, 64px);
}
Optimization time: 1–3 days, main work — images and fonts.







