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.
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-fullsetswidth: 100%, but without an explicitheightattribute or anaspect-ratiowrapper, 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-facedeclarations inside a Tailwind@layer baseblock and reference them with@applyutilities, the build pipeline can strip or override thefont-displaydescriptor. 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 thedarkclass. 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-lgbreakpoint stacks can cause reflows if the browser resizes while the page is rendering. Long paragraphs withtext-balancenot 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, orpaddingtrigger layout recalculations and contribute to CLS. The built-inanimate-pulseis safe because it only changesopacity, 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.
<!-- No height reserved -- image loads from zero height -->
<img
src="/product-hero.jpg"
alt="Product showcase"
class="w-full rounded-lg"
>
<!-- 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>
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.
// 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>
);
}
// 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.
@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.
/* 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;
}
}
/* 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;
}
}
/** @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.
<!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>
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class', // Must be 'class', not 'media'
content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
// 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.
<!-- 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>
/** @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.
<!-- 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"
>
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.
/** @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',
},
},
},
};
/* 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.
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, meaningdocument.documentElementexists 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 withdangerouslySetInnerHTMLas the first child of<body>. - Using aspect-ratio on the img element directly instead of a wrapper. Setting
aspect-videodirectly 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 intailwind.config.jsfor 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: transformCSS 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. Applywill-changeonly 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.
Related resources
Complete CLS Guide
The comprehensive guide to understanding and optimizing Cumulative Layout Shift across all frameworks.
GuideCSS Performance Guide
Deep dive into CSS properties that trigger layout, paint, and compositor layers.
FixFix CLS in React
Component-level patterns for eliminating layout shift in React applications.
FixFix CLS from Font Loading
Eliminate FOIT and FOUT across all frameworks with font-display and preloading strategies.
Continue learning
Fix CLS in Next.js
Next.js-specific CLS patterns including Image component configuration and App Router layout stability.
FixAnimation Performance and CLS
Keep animations on the compositor thread and prevent them from contributing to layout shift scores.
FixFix CLS in React
Suspense boundaries, lazy loading, and component-level strategies for eliminating React CLS.
FixFix CLS from Font Loading
Font-display strategies, preloading, and fallback font metric matching to prevent text-related shift.