CLS React 18+

Fix CLS in React

CLS in React applications has a few distinct causes that differ from server-rendered sites. The most React-specific issues are useEffect causing layout changes after the initial paint, Suspense boundaries replacing skeletons with differently-sized content, and CSS-in-JS libraries injecting styles after render. This guide covers each with working code fixes.

Expected results

Before

0.31

CLS (Poor) -- images without dimensions, useEffect layout changes, font reflow

After

0.04

CLS (Good) -- dimensioned images, stable skeletons, transform animations, size-adjusted fonts

Step-by-step fix

Set width and height on all images

In React, images without explicit dimensions collapse to 0x0 until they load, then expand -- causing the browser to reflow and shifting surrounding content. Always provide width and height attributes. Use CSS to make them responsive without removing the browser's space reservation.

JSX -- Images with dimensions
// Bad -- no dimensions, causes layout shift
<img src={product.imageUrl} alt={product.name} />

// Good -- dimensions let browser reserve space
<img
  src={product.imageUrl}
  alt={product.name}
  width={400}
  height={300}
  style={{ width: '100%', height: 'auto' }}
/>

// For unknown dimensions, use aspect-ratio with object-fit
<img
  src={product.imageUrl}
  alt={product.name}
  style={{
    width: '100%',
    aspectRatio: '4/3',
    objectFit: 'cover',
  }}
/>

Stabilize useEffect-driven content changes

A common React CLS pattern: component renders a skeleton/placeholder, then useEffect fires, loads data, and replaces the placeholder with content of a different height. Fix this by ensuring the placeholder matches the loaded content height, or by rendering the content server-side.

JSX -- Stable async content loading
// Bad: skeleton and loaded content have different heights
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  // Skeleton (50px) is replaced by profile card (200px) = CLS
  if (!user) return <div className="skeleton" style={{ height: '50px' }} />;
  return <ProfileCard user={user} />;  // 200px tall
}

// Good: skeleton matches loaded content height
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  // Same visual footprint as ProfileCard
  if (!user) return <ProfileCardSkeleton />;  // also 200px
  return <ProfileCard user={user} />;
}

Reserve space for async containers

For sections that load data asynchronously and have unpredictable content height, give the container a min-height that approximates the expected loaded height. This prevents the page from collapsing while content loads and eliminates the shift when content appears.

JSX -- Container with reserved height
// Good: container keeps its space while loading
function ProductGrid() {
  const { products, loading } = useProducts();

  return (
    <section
      style={{
        // Estimate: 3 rows of 300px cards with gap
        minHeight: loading ? '960px' : 'auto',
      }}
    >
      {loading ? (
        <div className="grid">
          {Array.from({ length: 9 }).map((_, i) => (
            <ProductSkeleton key={i} />
          ))}
        </div>
      ) : (
        <div className="grid">
          {products.map(p => <ProductCard key={p.id} product={p} />)}
        </div>
      )}
    </section>
  );
}

Use transform and opacity for animations

CSS animations and transitions that change height, margin, top, left, or width trigger layout recalculation and cause CLS. Replace these with transform and opacity, which are compositor-only and cause no layout shift.

CSS and JSX -- Compositor-safe animations
/* Bad: animating layout properties */
.modal {
  transition: top 0.3s ease;
}
.modal.open { top: 0; }   /* triggers layout each frame */

/* Good: transform does not trigger layout */
.modal {
  transform: translateY(-100%);
  transition: transform 0.3s ease;
}
.modal.open {
  transform: translateY(0);  /* compositor only */
}

/* In React with inline styles */
function Drawer({ isOpen }) {
  return (
    <div
      style={{
        transform: isOpen ? 'translateX(0)' : 'translateX(-100%)',
        transition: 'transform 0.25s ease',
        // Fixed positioning also doesn't cause CLS
        position: 'fixed',
        left: 0, top: 0,
      }}
    >
      {/* content */}
    </div>
  );
}

Handle web font loading

When a web font loads and replaces the fallback font, the text reflows if the two fonts have different metrics. Use font-display: swap in your @font-face declarations, and add size-adjust, ascent-override, and descent-override to create a dimensionally-matched fallback.

CSS -- Size-adjusted font fallback
/* Primary web font */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
}

/* Size-adjusted fallback that matches Inter's metrics */
@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  size-adjust: 107.4%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

/* Apply both in the font stack */
:root {
  font-family: 'Inter', 'Inter-fallback', system-ui, sans-serif;
}

/* Or in your React root */
/* In React: add to your global CSS file or CSS-in-JS global styles */

Quick checklist

  • All <img> elements have width and height attributes
  • Skeleton placeholders match loaded content height
  • Async containers have min-height while loading
  • All animations use transform and opacity only
  • Web fonts use font-display: swap with size-adjusted fallbacks
  • CSS-in-JS library uses SSR style extraction (if applicable)

Frequently asked questions

The most common causes: images without dimensions causing reflow when they load, useEffect-driven content changes that alter layout after initial paint, async data loading changing container heights, CSS animations on layout-triggering properties, and web font loading causing text reflow.

useEffect runs after the initial render and paint. If it sets state that changes rendered output -- swapping a placeholder for real content, or changing layout-affecting class names -- those changes happen after the initial paint and shift visible content. Fix by matching placeholder height to loaded content, or rendering content server-side.

StrictMode double-invokes render functions and effects in development to detect side effects. This can make CLS appear worse in development than production. Always measure CLS in production builds for accurate numbers.

When a lazy-loaded route mounts, the Suspense fallback is replaced by the real component, possibly changing page height. Use skeleton screens matching the target page height, or keep a consistent layout wrapper (navbar, footer) outside the Suspense boundary so only the content area changes.

Yes. styled-components and Emotion can cause FOUC in SSR setups if styles are not extracted. Use ServerStyleSheet (styled-components) or extractCritical (Emotion) to inject styles in the initial HTML and prevent CLS from style injection after paint.

Set up real-user monitoring using the web-vitals JavaScript library (1.5KB). Send CLS data to your analytics platform (Google Analytics 4, custom endpoint). The attribution build identifies exactly which element caused each layout shift. For React, also monitor CLS after route transitions, as client-side navigation can trigger additional shifts not captured in initial page load.

Continue learning