CLS Tailwind CSS v3 + v4

Fix CLS with Tailwind CSS: Stop Layout Shift in Utility-First Sites

Cumulative Layout Shift (CLS) is one of the most common performance regressions in Tailwind CSS projects, and also one of the most avoidable. Tailwind's utility-first model is excellent for building consistent interfaces quickly, but its defaults do not prevent layout shift out of the box. Images default to inline display with no reserved space, skeleton loader divs carry no height, font-face declarations get detached from their display settings when processed through @apply, and dark-mode class toggling can cause the entire page to repaint after first paint. Each pattern contributes directly to poor CLS scores that fail Google's Core Web Vitals thresholds.

This guide covers seven concrete fixes for the most common Tailwind CLS sources, with before-and-after code examples for each. The fixes apply to Tailwind CSS v3 and the new v4 oxide architecture. Real-world before/after: a content site using Tailwind with no CLS mitigation typically scores 0.18 in the poor range. After the fixes below, the same site reliably scores under 0.04. For deeper background on how CLS is measured and thresholds, see the complete CLS guide and the CSS performance guide.

TL;DR quick wins: Add aspect-video or aspect-[16/9] to every image wrapper. Set min-h-[value] on all async content containers. Inline a 3-line theme-detection script before <body>. Add explicit width and height attributes to every <img> tag alongside w-full h-auto. These four changes eliminate the majority of Tailwind CLS in most projects.

Expected results

Applying all seven fixes typically produces the following improvements on a content-heavy Tailwind site with images, async data, and a dark-mode toggle:

Before

0.18

CLS score (Poor) -- unoptimized Tailwind defaults with no aspect-ratio, no skeleton heights, and client-side dark mode

After

0.04

CLS score (Good) -- aspect-ratio utilities, min-height skeletons, font-display swap, and inline dark-mode script

Common causes of CLS in Tailwind CSS projects

Before jumping into fixes, it helps to understand where layout shift originates in utility-first workflows. The following causes account for more than 90% of CLS in Tailwind sites, based on audits across dozens of production projects:

  • Images without reserved space. Tailwind's w-full sets width: 100%, but without an explicit height attribute or an aspect-ratio wrapper, the browser cannot calculate the image's layout box until the file begins downloading. Content below the image shifts down when the image expands from zero height.
  • Async content containers with no minimum height. Cards, feeds, and data tables rendered after a JavaScript fetch start at zero height. When the data arrives and React or Vue inserts elements, everything below shifts. This is especially visible on slow connections where the skeleton flash is long.
  • Font-display conflicts via @apply. When you include @font-face declarations inside a Tailwind @layer base block and reference them with @apply utilities, the build pipeline can strip or override the font-display descriptor. The result is a flash of invisible text (FOIT) followed by a layout reflow as the web font loads and changes metrics from the system fallback.
  • Dark-mode toggle paint. Tailwind's darkMode: 'class' strategy requires JavaScript to apply the dark class. If the script runs after first paint, the browser repaints the entire page with dark-mode variants, and any elements whose dark-mode variants have different dimensions or font weights cause layout shift during or after that repaint.
  • Responsive font-size stacks changing line heights. Using text-sm md:text-base lg:text-lg breakpoint stacks can cause reflows if the browser resizes while the page is rendering. Long paragraphs with text-balance not applied can also reflow when the container width changes slightly around breakpoints.
  • Animation utilities touching layout properties. Custom animations or Tailwind plugins that animate width, height, margin, or padding trigger layout recalculations and contribute to CLS. The built-in animate-pulse is safe because it only changes opacity, but custom keyframe animations built on top of Tailwind often accidentally touch layout properties.

Step-by-step fix

Reserve image space with aspect-ratio utilities

Tailwind v3.2+ ships with first-class aspect-ratio utilities: aspect-video (16/9), aspect-square (1/1), and arbitrary values like aspect-[4/3] or aspect-[16/9]. Wrapping your images in a container that carries the correct aspect ratio gives the browser the layout dimensions before any bytes of the image file are downloaded.

The pattern is always the same: a wrapper div carries the aspect-ratio and relative classes, the image inside is positioned with absolute inset-0 and sized with w-full h-full object-cover. This is more reliable than setting dimensions on the image itself because it handles percentage-width containers correctly at all viewport sizes.

HTML + Tailwind -- Before (causes CLS)
<!-- No height reserved -- image loads from zero height -->
<img
  src="/product-hero.jpg"
  alt="Product showcase"
  class="w-full rounded-lg"
>
HTML + Tailwind -- After (no CLS)
<!-- Wrapper reserves 16:9 space before image loads -->
<div class="aspect-video relative overflow-hidden rounded-lg">
  <img
    src="/product-hero.jpg"
    alt="Product showcase"
    width="1200"
    height="675"
    class="absolute inset-0 w-full h-full object-cover"
    loading="eager"
  >
</div>

<!-- For square thumbnails -->
<div class="aspect-square relative overflow-hidden rounded-md">
  <img
    src="/avatar.jpg"
    alt="User avatar"
    width="80"
    height="80"
    class="absolute inset-0 w-full h-full object-cover"
  >
</div>

<!-- Custom ratio with JIT arbitrary value -->
<div class="aspect-[4/3] relative overflow-hidden rounded-xl">
  <img
    src="/blog-cover.jpg"
    alt="Blog cover"
    width="800"
    height="600"
    class="absolute inset-0 w-full h-full object-cover"
  >
</div>
Tailwind v4 note: In v4's oxide engine, aspect-ratio utilities work identically but you no longer need the aspect-ratio plugin import. The utilities are available by default without any configuration.

Build skeleton loaders with min-height utilities

Skeleton loaders only eliminate CLS if they reserve the exact dimensions of the content they represent. A skeleton div with no height starts at zero and collapses when the real content replaces it, triggering shift. The fix is to add min-h-[value] to every async content container and match it as closely as possible to the expected rendered height.

For content whose height varies (multi-line text, variable-length lists), use a minimum height that matches the minimum expected content and pair it with the animate-pulse utility to signal loading state. Because animate-pulse only transitions opacity, it does not contribute to layout shift.

JSX + Tailwind -- Skeleton without dimensions (causes CLS)
// CLS source: div starts at 0 height, content shifts in
function ArticleCard({ isLoading, article }) {
  if (isLoading) {
    return (
      <div class="animate-pulse bg-gray-200 rounded-lg">
        {/* No height -- collapses to zero */}
      </div>
    );
  }
  return (
    <div class="bg-white rounded-lg p-6">
      <h2>{article.title}</h2>
      <p>{article.excerpt}</p>
    </div>
  );
}
JSX + Tailwind -- Skeleton with reserved dimensions (no CLS)
// No CLS: skeleton matches the expected content height
function ArticleCardSkeleton() {
  return (
    <div class="animate-pulse bg-white rounded-lg p-6 min-h-[180px]">
      <div class="h-5 bg-gray-200 rounded w-3/4 mb-3"></div>
      <div class="h-4 bg-gray-100 rounded w-full mb-2"></div>
      <div class="h-4 bg-gray-100 rounded w-5/6 mb-2"></div>
      <div class="h-4 bg-gray-100 rounded w-4/6"></div>
      <div class="mt-4 aspect-video bg-gray-200 rounded-md"></div>
    </div>
  );
}

function ArticleCard({ isLoading, article }) {
  if (isLoading) return <ArticleCardSkeleton />;
  return (
    <div class="bg-white rounded-lg p-6 min-h-[180px]">
      <h2 class="text-lg font-semibold mb-3">{article.title}</h2>
      <p class="text-gray-600 text-sm">{article.excerpt}</p>
      <div class="mt-4 aspect-video relative overflow-hidden rounded-md">
        <img
          src={article.cover}
          alt={article.coverAlt}
          width="640"
          height="360"
          class="absolute inset-0 w-full h-full object-cover"
        />
      </div>
    </div>
  );
}

Fix font-display issues caused by @apply

A common Tailwind pattern is to declare custom @font-face rules inside a @layer base block in your main CSS file and then use @apply to compose utility classes. This pattern can cause the font-display descriptor to be ignored or defaulted to auto (which behaves like block in Chrome) because PostCSS and the Tailwind build pipeline process @layer blocks differently from top-level CSS.

The correct approach is to keep @font-face declarations outside all Tailwind layers, set font-display: swap directly on the declaration, and configure fontFamily in tailwind.config.js to reference the loaded font name. This decouples font loading from the Tailwind build and ensures the descriptor is always respected.

Common mistake: Placing @font-face inside @layer base { } and expecting font-display: swap to work. PostCSS reorders @layer contents during build, which can change the cascade order of font descriptors.
CSS -- Incorrect (font-display may be lost)
/* Problematic: @font-face inside @layer base */
@layer base {
  @font-face {
    font-family: 'CustomSans';
    src: url('/fonts/custom-sans.woff2') format('woff2');
    font-weight: 400;
    font-style: normal;
    font-display: swap; /* May be ignored inside @layer */
  }

  body {
    @apply font-sans text-gray-900;
  }
}
CSS + tailwind.config.js -- Correct (font-display always applies)
/* global.css -- @font-face OUTSIDE @layer blocks */
@font-face {
  font-family: 'CustomSans';
  src: url('/fonts/custom-sans-400.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'CustomSans';
  src: url('/fonts/custom-sans-700.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  body {
    font-family: 'CustomSans', ui-sans-serif, system-ui, sans-serif;
  }
}
tailwind.config.js -- Register the custom font family
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
  theme: {
    extend: {
      fontFamily: {
        sans: ['CustomSans', 'ui-sans-serif', 'system-ui', 'sans-serif'],
        mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
      },
    },
  },
  plugins: [],
};

For more detail on how font loading and CLS interact across frameworks, see the dedicated font loading CLS fix guide.

Eliminate dark-mode toggle CLS

Tailwind's darkMode: 'class' strategy applies dark-mode variant utilities only when the dark class is present on a parent element (typically <html>). When this class is applied by JavaScript after the page has painted, the browser applies an entirely new set of CSS rules to every element with dark: variants. If any dark-mode variant changes an element's dimensions, font metrics, or padding, CLS is recorded for those elements.

The solution is to apply the theme class synchronously before first paint by inlining a blocking script immediately before the opening <body> tag (not in the <head>, and not as a deferred or async script). The script reads localStorage and applies the correct class in the same synchronous paint frame as HTML parsing.

HTML -- Dark mode script placement (inline, blocking, before body content)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <!-- fonts, styles, etc -->
</head>
<body>
  <!-- Inline script runs synchronously BEFORE any DOM is painted.
       This is intentionally render-blocking to prevent flash. -->
  <script>
    (function() {
      var theme = localStorage.getItem('theme');
      var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      if (theme === 'dark' || (!theme && prefersDark)) {
        document.documentElement.classList.add('dark');
      }
    })();
  </script>

  <!-- rest of body content -->
</body>
</html>
tailwind.config.js -- Enable class strategy for dark mode
/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: 'class', // Must be 'class', not 'media'
  content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
  theme: {
    extend: {},
  },
  plugins: [],
};
JavaScript -- Theme toggle button handler
// Theme toggle -- updates class AND localStorage atomically
function toggleTheme() {
  const isDark = document.documentElement.classList.toggle('dark');
  localStorage.setItem('theme', isDark ? 'dark' : 'light');
}

document.querySelector('[data-theme-toggle]')
  .addEventListener('click', toggleTheme);

For a deeper dive into how React and Next.js both handle this pattern and where each framework introduces CLS risk, see Fix CLS in React and Fix CLS in Next.js.

Use fluid typography with clamp and text-balance

Responsive font-size breakpoint stacks (text-sm md:text-base lg:text-lg) change text metrics at defined viewport widths. When a user's viewport is exactly at a breakpoint boundary, resizing by even one pixel triggers a font-size change that reflowing a block of text. text-balance on headings and text-pretty on paragraphs prevent widows and orphans, but they can also trigger micro-reflows during layout calculation if the text length sits near a line-break boundary.

Replacing breakpoint font-size stacks with clamp()-based fluid sizing eliminates the sharp transitions. Tailwind v3.3+ supports arbitrary values with clamp() directly: text-[clamp(1rem,2.5vw,1.5rem)]. For v4, you can register fluid size tokens in the CSS-first config using --text-fluid-* custom properties.

HTML + Tailwind v3 -- Fluid typography (no breakpoint reflows)
<!-- Instead of: text-xl md:text-2xl lg:text-3xl -->
<h1 class="text-[clamp(1.5rem,4vw,2.5rem)] font-bold text-balance leading-tight">
  The Performance Fix That Changes Everything
</h1>

<p class="text-[clamp(0.9375rem,1.5vw,1.0625rem)] text-pretty leading-relaxed">
  Long paragraph content that benefits from text-pretty to avoid
  single-word orphans on the last line, which trigger micro-reflows
  in certain browsers during layout.
</p>
tailwind.config.js -- Registering fluid type scale as custom utilities
/** @type {import('tailwindcss').Config} */
const plugin = require('tailwindcss/plugin');

module.exports = {
  theme: {
    extend: {},
  },
  plugins: [
    plugin(function ({ addUtilities }) {
      addUtilities({
        '.text-fluid-sm':  { fontSize: 'clamp(0.875rem, 1.2vw, 1rem)' },
        '.text-fluid-base': { fontSize: 'clamp(1rem, 1.5vw, 1.125rem)' },
        '.text-fluid-lg':  { fontSize: 'clamp(1.125rem, 2vw, 1.5rem)' },
        '.text-fluid-xl':  { fontSize: 'clamp(1.25rem, 2.5vw, 2rem)' },
        '.text-fluid-2xl': { fontSize: 'clamp(1.5rem, 4vw, 2.5rem)' },
      });
    }),
  ],
};

Apply intrinsic sizing to all images

Even when wrapping images in aspect-ratio containers as described in step 1, you should still set explicit width and height HTML attributes on every <img> element. These attributes serve as layout hints to the browser independently of any CSS. When the browser encounters an <img> with both attributes, it calculates the aspect ratio from the attribute values during HTML parsing, before any CSS or JavaScript is processed. This is the earliest possible point at which layout space can be reserved.

The interaction with Tailwind is simple: setting width and height attributes on an element does not conflict with CSS classes. class="w-full h-auto" overrides the rendered dimensions via CSS cascade, but the browser still uses the attribute values to infer the intrinsic aspect ratio for layout calculation. This dual-path sizing is the most robust CLS prevention available for images.

HTML + Tailwind -- Intrinsic sizing best practice
<!-- Both width/height attrs AND Tailwind responsive classes -->
<img
  src="/hero.jpg"
  alt="Hero image"
  width="1200"
  height="675"
  class="w-full h-auto block rounded-lg"
>

<!-- In a responsive grid with different sizes at different breakpoints -->
<img
  src="/thumbnail.jpg"
  alt="Article thumbnail"
  width="400"
  height="300"
  class="w-full h-auto object-cover aspect-[4/3]"
>

<!-- With srcset for responsive images -->
<img
  src="/hero-800.jpg"
  srcset="/hero-400.jpg 400w, /hero-800.jpg 800w, /hero-1600.jpg 1600w"
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 800px"
  alt="Responsive hero"
  width="800"
  height="450"
  class="w-full h-auto block"
>
Automation tip: Run npx sharp-cli or probe-image-size in your build pipeline to automatically extract image dimensions and inject them as width and height attributes during static site generation. Both tools support batch processing and can be integrated into Vite, webpack, or Eleventy build scripts.

Audit animation utilities for layout-triggering properties

CSS animations that change layout properties (width, height, margin, padding, top, left, right, bottom) trigger full layout recalculations on every animation frame and directly contribute to CLS. The browser's layout engine treats these changes as layout-affecting, meaning every other element on the page must be re-evaluated for position during the animation. Tailwind's built-in animate-pulse, animate-spin, animate-bounce, and animate-ping are safe because they use opacity, transform, or both.

The risk comes from custom animations added via @keyframes in tailwind.config.js or inline style tags, and from third-party Tailwind plugins that add animation utilities. Audit every custom animation in your codebase and replace layout-triggering properties with transform equivalents. For more detail on animation best practices and their CLS impact, see the dedicated animation performance CLS guide.

tailwind.config.js -- Safe custom animations using only transform/opacity
/** @type {import('tailwindcss').Config} */
module.exports = {
  theme: {
    extend: {
      keyframes: {
        // SAFE: only uses transform and opacity
        'slide-up': {
          '0%':   { transform: 'translateY(12px)', opacity: '0' },
          '100%': { transform: 'translateY(0)',    opacity: '1' },
        },
        'fade-in': {
          '0%':   { opacity: '0' },
          '100%': { opacity: '1' },
        },
        'scale-in': {
          '0%':   { transform: 'scale(0.95)', opacity: '0' },
          '100%': { transform: 'scale(1)',    opacity: '1' },
        },

        // UNSAFE: animates layout properties (causes CLS)
        // 'expand': {
        //   '0%':   { height: '0', overflow: 'hidden' },
        //   '100%': { height: '200px' },
        // },
      },
      animation: {
        'slide-up': 'slide-up 0.25s ease-out both',
        'fade-in':  'fade-in  0.2s  ease-out both',
        'scale-in': 'scale-in 0.2s  ease-out both',
      },
    },
  },
};
CSS -- Marking animations as safe with will-change and contain
/* Add to elements that animate to promote them to compositor layer */
.animate-slide-up,
.animate-fade-in,
.animate-scale-in {
  will-change: transform, opacity;
  contain: layout style;
}

/* After animation completes, remove will-change to free GPU memory */
.animation-complete {
  will-change: auto;
}

Verification

After applying these fixes, measure CLS using a combination of lab and field tools to confirm the improvements are real and not just lab artifacts.

Lab measurement (immediate feedback): Run Lighthouse in Chrome DevTools with the Performance category selected. The CLS score and a breakdown of contributing elements appears under the "Avoid large layout shifts" audit. Each contributing shift lists the element, the shift fraction, and the timestamp. Target a Lighthouse CLS score below 0.1 with zero individual shifts above 0.05.

WebPageTest trace analysis: Run a WebPageTest test at webpagetest.org with a Filmstrip view enabled. Step through the frames and identify the exact moment each layout shift occurs. This is more reliable than Lighthouse for finding shifts caused by late-loading web fonts or asynchronous content because WebPageTest captures the full loading timeline including real network conditions.

Field measurement (real users): Install the web-vitals npm package (v4.2.0+) and log CLS values from real user sessions. The onCLS callback fires at end-of-session with the final accumulated score. Pay particular attention to the 75th percentile value -- Google's Core Web Vitals assessment uses 75th percentile field data from the Chrome User Experience Report (CrUX), not median or average values.

JavaScript -- Logging CLS from real users with web-vitals v4
import { onCLS } from 'web-vitals';

onCLS(({ value, entries, id }) => {
  console.log(`CLS: ${value.toFixed(4)}`);

  // Log each contributing shift for debugging
  entries.forEach((entry) => {
    console.log('Shift source:', entry.sources?.map(s => ({
      node: s.node?.tagName,
      previousRect: s.previousRect,
      currentRect: s.currentRect,
    })));
  });

  // Send to your analytics
  navigator.sendBeacon('/analytics', JSON.stringify({
    metric: 'CLS',
    value,
    id,
    url: location.href,
  }));
});

Common pitfalls

  • Forgetting the inline dark-mode script in framework-generated HTML. In Next.js, Remix, Astro, and SvelteKit, the <body> tag is generated by the framework. Many developers add the theme detection script to <head> via the framework's head management API, but scripts in <head> run before the DOM is attached, meaning document.documentElement exists but the body is not yet accessible. Place the script as close to the opening <body> as the framework allows. In Next.js App Router, use a layout component that renders a <script> element with dangerouslySetInnerHTML as the first child of <body>.
  • Using aspect-ratio on the img element directly instead of a wrapper. Setting aspect-video directly on an <img> tag works, but it forces the browser to size the image to that ratio regardless of the natural image dimensions. This can cause object-fit clipping at unexpected breakpoints. The wrapper pattern is more predictable and composable with Tailwind's responsive variants.
  • Skeleton heights that don't match real content. If your skeleton loader reserves 120px but the real content renders at 180px when data loads, the 60px expansion is still recorded as CLS. Measure your actual rendered component heights in DevTools at several viewport widths and set min-h-[value] to the minimum expected height, not a rough estimate.
  • JIT mode not purging unused aspect-ratio or animation utilities. Tailwind's JIT (Just-in-Time) engine scans your content files for class names at build time. If you construct class names dynamically (e.g., `aspect-[${ratio}]`), JIT cannot detect them and the classes will not be in the production CSS bundle. Use a safelist in tailwind.config.js for dynamically constructed class names, or restructure the code to use static class selection with a lookup object.
  • Applying will-change to too many elements. The will-change: transform CSS property promotes elements to their own compositor layer, which uses GPU memory. Adding it to dozens of animated elements simultaneously can cause memory pressure on mobile devices, leading to dropped frames that ironically worsen perceived performance. Apply will-change only to elements that are actively animating and remove it after the animation completes.

Frequently asked questions

Tailwind does not inherently cause more CLS than other CSS methodologies, but its utility-first nature can mask layout shift sources. Developers often size elements with fixed pixel utilities without accounting for dynamic content, skip explicit image dimensions because w-full looks correct visually, and lean on Tailwind plugins for fonts without configuring font-display. The result is CLS that is harder to trace because there is no single class or component responsible. The good news is that Tailwind's utility model makes the fixes composable and easy to apply systematically.

Yes, for all browsers that support the CSS aspect-ratio property, which covers all modern browsers as of 2023. The aspect-ratio utility (aspect-video, aspect-square, aspect-[16/9]) is the canonical replacement for the padding-top percentage hack. The padding-top technique is only needed if you must support legacy browsers such as IE 11 or Safari versions below 15. For any project targeting modern browsers, use aspect-ratio utilities without hesitation.

When using Tailwind's class strategy for dark mode, JavaScript must read localStorage and apply the dark class before first paint. If this script runs after the browser has already painted, the color scheme swap triggers a repaint that can cause visible flash and, in some layouts, a layout shift if dark-mode variants change element dimensions or font weights. The fix is to inline the theme-detection script as a synchronous blocking script immediately before the body tag so it runs in the same frame as HTML parsing.

Skeleton loaders prevent CLS only when they reserve the correct final dimensions of the content they replace. A skeleton div with no explicit height starts at zero and causes CLS when the real content loads and expands the container. Use min-h-[value] or aspect-ratio utilities on skeleton wrappers to match the expected content dimensions. Animate skeletons with animate-pulse rather than custom keyframe animations that change layout properties.

A Tailwind site with unoptimized images, no skeleton dimensions, and a client-side dark-mode toggle typically scores CLS between 0.15 and 0.25 in the poor range. After applying aspect-ratio utilities for all media, min-height skeletons, font-display swap, and an inline dark-mode script, expect CLS to drop below 0.05 -- well into the good threshold of under 0.1. The exact improvement depends on how much dynamic content is present and how many layout-affecting transitions existed before the fix. Measure with field data from real users using the web-vitals library to confirm improvements at the 75th percentile.

Continue learning