Skip to main content

Prevent Layout Shifts: Fonts, Images & Animations

Preventing layout shifts requires coordinating three areas: font loading strategy, image sizing, and animation techniques. Each area has a specific best practice that, when combined, eliminates the vast majority of CLS-causing shifts. A React app with proper font loading, explicit image dimensions, and CSS-based animations will typically achieve a CLS under 0.05, well into the "good" range.

Font Loading: font-display: swap

The most impactful CLS fix is using font-display: swap in your @font-face declarations. This tells the browser to show text in a fallback font immediately while the custom font downloads in the background. Once the custom font arrives, the text is swapped—a one-time re-flow, but the text is readable from the start.

/* src/styles/fonts.css */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-display: swap; /* Show fallback immediately, swap when ready */
font-weight: 100 900;
font-style: normal;
}

@font-face {
font-family: 'Playfair Display';
src: url('/fonts/playfair.woff2') format('woff2');
font-display: swap;
font-weight: 400 700;
}

body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* ^ Fallback fonts ensure text renders in a similar-width font */
}

h1, h2, h3 {
font-family: 'Playfair Display', Georgia, serif;
}

Comparison of font-display options:

OptionBehaviorCLS Impact
auto (default)Invisible text for up to 3 seconds, then fallbackVery high (~0.2–0.3)
blockInvisible text for up to 3 seconds, then custom fontVery high (~0.2–0.3)
swapFallback text immediately, swap when custom font readyLow (~0.02–0.05)
fallbackBrief invisible period (100 ms), fallback for 3 seconds, then swapLow (~0.02–0.05)
optionalFallback if font slow, no swapVery low (~0–0.02)

font-display: swap is the best default for most sites. It keeps text readable and visible from the start, minimizing perceived delay and CLS.

Preloading Fonts to Reduce CLS Timing

Preloading fonts makes them arrive earlier, reducing the time the fallback font is visible:

<!-- public/index.html -->
<head>
<!-- Preload the primary font (variable font, optimized) -->
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>

<!-- Preload secondary font if used above-the-fold -->
<link
rel="preload"
href="/fonts/playfair.woff2"
as="font"
type="font/woff2"
crossorigin
/>

<!-- Font stylesheet (uses @font-face with font-display: swap) -->
<link rel="stylesheet" href="/styles/fonts.css">
</head>

Preloading a font reduces its download time by 100–300 ms, minimizing the CLS event.

Image Dimensions: Prevent Reflow

Always set explicit width and height on images. The browser uses these to allocate space, preventing reflow when the image loads.

// src/components/ProductCard.jsx

// BAD: No dimensions
function ProductCardBad({ product }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
{/* ^ No width/height; layout waits for image to load */}
<h2>{product.name}</h2>
<p>${product.price}</p>
</div>
);
}

// GOOD: Explicit dimensions
function ProductCard({ product }) {
return (
<div className="card">
<img
src={product.image}
alt={product.name}
width="400"
height="300"
// ^ Browser pre-allocates space, no reflow on load
/>
<h2>{product.name}</h2>
<p>${product.price}</p>
</div>
);
}

The width and height attributes create an aspect ratio that the browser reserves space for, even before the image arrives.

Responsive Images with aspect-ratio CSS

For responsive images that scale with the viewport, use CSS aspect-ratio to maintain proportions:

/* src/styles/images.css */
img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3; /* Maintain 4:3 ratio */
/* ^ Browser calculates height based on aspect ratio */
}

.hero-image {
aspect-ratio: 16 / 9;
}

.thumbnail {
aspect-ratio: 1 / 1;
}

React:

export default function ResponsiveImage({ src, alt }) {
return (
<img
src={src}
alt={alt}
className="hero-image"
// CSS handles width/height; aspect-ratio prevents reflow
/>
);
}

Skeleton Screens and Placeholders

For dynamic content (API-loaded images, data), show a skeleton screen or placeholder while the content loads:

// src/components/ProductList.jsx
import { useState, useEffect } from 'react';

export default function ProductList() {
const [products, setProducts] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => {
setProducts(data);
setLoading(false);
});
}, []);

if (loading) {
// Skeleton screen matches the height/width of real content
return (
<div className="product-list">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton-card">
<div className="skeleton-image" /> {/* Same height as real image */}
<div className="skeleton-text" />
<div className="skeleton-text" />
</div>
))}
</div>
);
}

return (
<div className="product-list">
{products.map(product => (
<div key={product.id} className="card">
<img
src={product.image}
alt={product.name}
width="400"
height="300"
/>
<h3>{product.name}</h3>
</div>
))}
</div>
);
}

CSS:

.skeleton-card {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5rem;
}

.skeleton-image {
width: 100%;
aspect-ratio: 4 / 3;
background: linear-gradient(90deg, #f0f0f0, #e0e0e0, #f0f0f0);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}

.skeleton-text {
width: 100%;
height: 1rem;
background: linear-gradient(90deg, #f0f0f0, #e0e0e0, #f0f0f0);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 0.25rem;
}

@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

The skeleton screen occupies the exact same space as the real content, so no layout shift occurs when the content arrives.

Animations Without Layout Shifts

Use CSS transforms (GPU-accelerated) for animations instead of layout properties. Transforms do not trigger reflows:

// src/components/Modal.jsx

// BAD: margin/height changes cause reflow
function ModalBad() {
const [open, setOpen] = useState(false);

return (
<>
<button onClick={() => setOpen(true)}>Open</button>
{open && (
<div
style={{
position: 'fixed',
top: open ? '0' : '-100%', // Animating position shifts content
transition: 'top 0.3s ease',
}}
>
Modal content
</div>
)}
</>
);
}

// GOOD: transform is GPU-accelerated, no reflow
function ModalGood() {
const [open, setOpen] = useState(false);

return (
<>
<button onClick={() => setOpen(true)}>Open</button>
<div
style={{
position: 'fixed',
top: 0,
left: 0,
transform: open ? 'scale(1)' : 'scale(0)',
opacity: open ? 1 : 0,
transition: 'transform 0.3s ease, opacity 0.3s ease',
pointerEvents: open ? 'auto' : 'none',
}}
>
Modal content
</div>
</>
);
}

Properties that trigger layout shifts (reflow):

  • top, bottom, left, right
  • width, height, margin, padding
  • position (static ↔ other)

Properties that do NOT trigger reflow (GPU-accelerated):

  • transform (translate, scale, rotate)
  • opacity
  • filter

Checking CLS with Lighthouse

Run Lighthouse and look for "Cumulative Layout Shift" in the report. It shows:

  • Total CLS score (0–1.0)
  • Shifts detected and their impact fractions
  • Elements that shifted

A CLS under 0.1 is "good" and appears as green in Lighthouse.

Key Takeaways

  • Use font-display: swap to show fallback text immediately while custom fonts load.
  • Preload critical fonts to reduce the time the fallback is visible.
  • Always set explicit width and height on images to pre-allocate space.
  • Use aspect-ratio CSS for responsive images without layout shifts.
  • Show skeleton screens with the same dimensions as real content to avoid shifts on load.
  • Use CSS transforms (not layout properties) for animations to avoid reflows.
  • GPU-accelerated properties (transform, opacity, filter) have zero CLS impact.

Frequently Asked Questions

Does font-display: swap cause a visible flicker?

Minimally. Text may appear in the fallback font for 50–200 ms, then swap to the custom font. Most users don't notice because the transition is fast. The perceived "flicker" is much less jarring than 2–3 second invisible text (with font-display: auto).

What if my images don't have static dimensions (responsive layout)?

Use CSS aspect-ratio to maintain proportions. The browser calculates the height based on the container width and aspect ratio, so no reflow occurs:

img {
width: 100%;
aspect-ratio: 4 / 3;
}

Can I delay hiding ads with visibility: hidden to prevent CLS?

Yes. Use visibility: hidden (reserves space) instead of display: none (removes space):

.ad {
min-height: 250px;
visibility: hidden; /* Reserves space but hides content */
}

.ad.loaded {
visibility: visible;
}

When the ad loads, toggle the class and the ad appears without shifting layout.

Does requestAnimationFrame improve CLS for animations?

No. The CLS metric is not affected by animation performance. However, requestAnimationFrame ensures smooth animations by syncing with the display's refresh rate. Always use requestAnimationFrame for animations, but rely on CSS transforms to avoid CLS, not requestAnimationFrame.

Further Reading