Tailwind CSS v4 already produces small stylesheets because it only generates the utilities your source files use. Most performance work is about making sure it scans the right files, that your build is minified, and that the things around your CSS (images, fonts, animations and scripts) do not slow the page down. This guide walks through each of those in order.
Everything below applies to v4's CSS-first setup: @import "tailwindcss" in your stylesheet, no tailwind.config.js required.
Table of contents
- How Tailwind v4 builds your CSS
- Control source detection
- Avoid dynamic class names
- Set up a production build
- Keep the theme lean
- Images, fonts and layout shift
- Animations that stay smooth
- FAQ
How Tailwind v4 builds your CSS
Tailwind scans your project files as plain text, looks for tokens that could be class names, and generates CSS only for the ones that match a utility or variant. There is no separate purge step like in Tailwind v2. If bg-indigo-600 appears nowhere in your source, it is not in the output.
Version 4 also changed how the work happens. The engine was rewritten (the project calls it Oxide), Lightning CSS handles vendor prefixing and syntax lowering, and builds are incremental in watch mode. You get these by using one of the official integrations:
@tailwindcss/vitefor Vite-based projects@tailwindcss/postcssfor Next.js, webpack and other PostCSS setups@tailwindcss/clifor a standalone build with no bundler
The browser build (@tailwindcss/browser) compiles classes at runtime in the page. It is useful for prototypes and demos, but it ships a JavaScript compiler to every visitor, so do not use it in production.
Control source detection
v4 finds source files automatically. It skips files listed in .gitignore, binary files, CSS files and lock files. For most projects that is enough, but two situations need adjustment.
Add files Tailwind cannot see
If your markup lives in a package inside node_modules or in a folder that is gitignored, register it with @source. Paths are relative to the stylesheet.
@import "tailwindcss";
@source "../node_modules/@acme/ui/dist";
@source "../content/blocks";
Exclude files that add noise
Large folders full of text, such as generated docs, fixtures, or a CMS export, can add unused utilities if they happen to contain words that match class names. Exclude them with @source not:
@import "tailwindcss";
@source not "../fixtures";
@source not "../docs/generated";
For full control, turn off automatic detection and list sources yourself. You can also set a base path for detection:
/* Only scan what you list */
@import "tailwindcss" source(none);
@source "../src/components";
@source "../src/app";
/* Or scan a single directory automatically */
@import "tailwindcss" source("../src");
In a monorepo this is often the biggest win, because a stylesheet in one app should not pick up class names from every other app in the repository.
Avoid dynamic class names
Because Tailwind reads files as text, it cannot see class names that are assembled at runtime. This is the most common reason a style "works in development but not in production," and the usual workaround (adding everything to a safelist) increases CSS size.
<!-- Tailwind cannot detect these -->
<div class="bg-{{ color }}-600">...</div>
<div class="text-{{ size }}">...</div>
<!-- Map props to complete class names instead -->
<!-- e.g. { primary: "bg-indigo-600", danger: "bg-red-600" } -->
<div class="bg-indigo-600">...</div>
Keep a lookup object of full class strings in your component code and pick from it. Every class appears in full somewhere in the source, so detection works and nothing extra is generated.
When you do need classes that never appear in source, for example classes stored in a database, v4 replaces the old safelist option with @source inline(). It supports brace expansion, so keep the list as narrow as you can:
@import "tailwindcss";
@source inline("{hover:,}bg-{indigo,emerald}-{500,600}");
Set up a production build
A production stylesheet should be minified and served with compression and long cache headers.
# CLI build for production
npx @tailwindcss/cli -i ./src/input.css -o ./dist/output.css --minify
With Vite or Next.js, production builds minify CSS by default, so the main job is to check what ships. Open your built CSS file and look for surprises: utilities you never wrote, very long selector lists, or duplicated @font-face rules. Those usually point to a source detection issue or a stylesheet imported in more than one entry point.
Other build checklist items:
- Import Tailwind once. Multiple
@import "tailwindcss"statements in separate entry files produce duplicate base styles. - Serve CSS with Brotli or gzip compression. Utility CSS has a lot of repetition and compresses well.
- Use hashed filenames so you can set long cache lifetimes.
- Load the main stylesheet with a normal
<link rel="stylesheet">in the<head>. Tailwind output is small enough that splitting critical CSS by hand is rarely worth the complexity.
Keep the theme lean
In v4 your design tokens live in CSS with @theme. Each token creates utilities and a CSS custom property. By default only the variables your code uses are emitted, so adding tokens does not grow the output until you use them.
@import "tailwindcss";
@theme {
--color-brand-600: oklch(0.51 0.23 277);
--font-display: "Inter", ui-sans-serif, system-ui, sans-serif;
--breakpoint-3xl: 120rem;
}
A few habits keep things tidy:
- If you are building a brand palette, reset the default colors with
--color-*: initial;so stray default shades in your markup are easy to spot. - Use
@theme staticonly when you need every variable available at runtime, for example for JavaScript that reads them. It forces all variables into the output. - Prefer
@utilityfor reusable custom utilities instead of large@applyblocks. Custom utilities created with@utilitywork with variants and are only generated when used.
@utility content-auto {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}
Images, fonts and layout shift
On most landing pages, the stylesheet is a small part of the page weight. Images and fonts usually matter more for Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS).
Images
<!-- Hero image: load early -->
<img src="/hero.avif" alt="Team reviewing a dashboard" width="1600" height="900"
fetchpriority="high" class="aspect-video w-full rounded-2xl object-cover" />
<!-- Below the fold: load lazily -->
<img src="/feature.avif" alt="Report export screen" width="800" height="600"
loading="lazy" decoding="async" class="aspect-4/3 w-full rounded-xl object-cover" />
- Set
widthandheightor an aspect ratio class likeaspect-videoso the browser reserves space and the page does not shift. - Do not lazy-load the LCP image. Give it
fetchpriority="high"instead. - Serve AVIF or WebP at the size it is displayed, with
srcsetfor different widths.
Fonts
Self-host one or two font families, use WOFF2, add font-display: swap, and preload the file used above the fold. Register the family as --font-display inside @theme, as in the example above, and apply it with the font-display utility. Each extra weight is another request, so check which weights the design actually uses.
Long pages
Landing pages built from many sections can benefit from content-visibility: auto on sections below the fold, which lets the browser skip rendering work until they are near the viewport. The content-auto utility above, or an arbitrary property like [content-visibility:auto], applies it. Give it a reasonable contain-intrinsic-size so the scrollbar does not jump.
Animations that stay smooth
Animations affect responsiveness more than load time. Two rules cover most cases:
- Animate
transformandopacity. Classes liketranslate-y-4,scale-95andopacity-0are cheap for the browser to animate. Animatingwidth,height,topormargintriggers layout on every frame. - Use
transition-transformortransition-opacityinstead oftransition-allwhen you only change one property.
<div class="translate-y-4 opacity-0 transition duration-500 ease-out data-[visible]:translate-y-0 data-[visible]:opacity-100 motion-reduce:transition-none">
...
</div>
Use will-change-transform sparingly, only on elements that animate continuously, such as a marquee. Applying it to dozens of elements uses extra memory. For scroll-triggered effects, use a single IntersectionObserver that sets a data attribute, rather than a scroll event listener.
OpenTailwind blocks follow this pattern, with dark mode and scroll animations built from standard classes. You can compare approaches across the stats and hero categories, or start from a full page in the technology and SaaS templates.
FAQ
Do I need PurgeCSS with Tailwind v4?
No. Tailwind generates only the classes it finds in your source files. PurgeCSS was common with Tailwind v1 and is not needed now.
Why is a class missing from my production build?
Usually the class name is built dynamically, or the file that uses it is not scanned (for example, it is gitignored or in node_modules). Write the full class name in source, or add the path with @source.
Is @apply bad for performance?
Not by itself, but large @apply blocks duplicate declarations that utilities would share. Use components in your framework or @utility for repeated patterns.
How do I measure the result?
Use Lighthouse or PageSpeed Insights for lab data and the Chrome User Experience Report or your own real user monitoring for field data. Check LCP, CLS and Interaction to Next Paint, not only the CSS file size.
Tailwind v4 handles most CSS optimization for you, as long as it scans the right files and you write class names in full. After that, focus on the parts of the page that weigh more: properly sized images with reserved space, a small set of self-hosted fonts, and animations that stick to transforms and opacity. Check the built CSS once per release and measure with real metrics rather than guessing.