Tooling and Maintenance

Tailwind CSS Intermediate/Advanced

Chapter 7 ยท Performance, Tooling and Maintenance

A Tailwind project is easy to start and, after a year, easy to let go untidy: class lists in different orders, typos that silently do nothing, conflicting classes, a stylesheet nobody has looked at. This chapter measures the things people worry about (output size, build time), then sets up the tools that keep a project tidy (Prettier, ESLint, editor settings), and finishes with a way to catch visual changes and a review checklist.

Every example here was run, except where marked
Tailwind 4.3.3, Node 24.18.0, Windows 11, Chrome 154 headless. Tools: prettier 3.9.10 with prettier-plugin-tailwindcss 0.8.1, eslint 10.12.0 with eslint-plugin-tailwindcss 4.4.0 and eslint-plugin-better-tailwindcss 4.9.0, @typescript-eslint/parser 8.71.1, pixelmatch and pngjs. Timings come from one Windows laptop and a synthetic project, so they show proportions, not what your machine will do. Not run: the VS Code extension (its settings are from its README), the Tailwind release notes' speed figures, Safari and Firefox, other bundlers' build times, and a real CI pipeline.

What Decides the Size of the Output

The generated CSS contains one rule per distinct class that the scanner found (chapter 1), plus the reset and the theme variables you use. It does not grow with the number of files or with how often a class repeats. I generated three projects of 500 files each, with 30 random classes per file, drawn from pools of different sizes, and built each from inside its own folder:

ProjectDistinct classesPlain--minifyMinified + gzip
small pool15018,992 B13,605 B3,072 B
colours, spacing, sizes78553,825 B41,673 B7,050 B
the same 785 with 5 variant prefixes (hover:, md:, dark:, focus:, lg:hover:)4,513326,471 B239,489 B29,008 B

Size follows the number of distinct class-plus-variant combinations. Every extra variant on a class is another rule. A page that is 30 KB gzipped after minifying has thousands of distinct combinations, not thousands of elements. Separately, three settings changed the size of the 150-class project (13,605 B minified, 3,062 B gzipped):

@import "tailwindcss"; /* default */ @import "tailwindcss" theme(static); /* write every theme variable */ @layer theme, base, components, utilities; @import "tailwindcss/theme.css" layer(theme); /* no reset (preflight) */ @import "tailwindcss/utilities.css" layer(utilities);
default 13,605 B gzip 3,062 B theme(static) 27,524 B gzip 6,647 B every variable written whether used or not theme + utilities only 9,953 B gzip 1,907 B no preflight: the reset is gone (check that your pages still look right) default, --optimize, no minify 17,309 B (--optimize is not --minify; chapter 1)

Two honest limits. First, I did not test whether dropping the reset is safe for your pages; the saving (3.6 KB here) is the reset's cost, and the browsers will then apply their own default margins and fonts. Second, the build already writes only the theme variables you use, so theme(static) is the expensive direction; use it only when something outside Tailwind reads the variables.

Build Time

On the 4,513-class project (500 files) I first timed npx @tailwindcss/cli โ€ฆ and got about 3.5 seconds for every project, whatever its size. That was npx starting up. Calling the program directly (node node_modules/@tailwindcss/cli/dist/index.mjs) and reading the CLI's own "Done in" line gave a very different picture:

cold build, 4,513 classes, 500 files: Done in 225 ms, 328 ms, 218 ms (three runs) cold build, 150 classes, 500 files: Done in 120 ms, 121 ms, 251 ms watch mode (--watch=always), same large project, one edit at a time: start-up 356 ms append a class to a content file (x3) lines of 5, 48, 4, 69, 6, 33, 82, 66 ms and 401 ยตs add an arbitrary-value class (text-[#abc123]) included in the lines above; output contained it edit the CSS file (add a @theme colour) 270 ms; the new bg-zz class appeared in the output

Watch mode printed several "Done" lines for each edit (the file system reports more than one event), so I cannot say which line belongs to which edit; the range is what I can state. Content edits were from under a millisecond to about 80 ms; changing the CSS file took 270 ms, close to a cold build, because the theme may have changed. I did not verify the speed-up figures in Tailwind's release notes, and I did not time bundler integrations (Vite, Next.js). If a build is slow, measure the tool itself with its own timer before blaming Tailwind.

Browser Support Is a Decision

Tailwind's compatibility page says v4 "is designed for and tested on modern browsers" and depends on Chrome 111 (March 2023), Safari 16.4 (March 2023) and Firefox 128 (July 2024). The page does not say which features set those numbers. I built a page with a shadow, a ring, a translate, a rotate, an opacity colour, a hover colour and a backdrop blur and counted what the CSS uses:

@layer 6 cascade layers (chapter 1) @property 26 registered custom properties (--tw-translate-x, --tw-shadow, ...) @supports 4 feature tests around newer syntax color-mix 5 opacity on theme colours, placeholder colour @media (hover 1 hover: only where a hover pointer exists oklch 3 colours (the full palette has about 50 in a small build)

That is the list your decision rests on: layers, @property, color-mix() and oklch(). If a real share of your visitors use an older browser, Tailwind v4 is the wrong choice and staying on v3 is the supported one (chapter 8 covers the migration and when not to). Check your own analytics, not a general rule. Tailwind's page also says the bleeding-edge utilities (field-sizing, @starting-style, text-wrap: balance) are optional: do not use them if your targets lack them. I did not test any browser except Chrome.

Sorting Classes with Prettier

A class list in a different order on every element makes diffs noisy and reviews slower. prettier-plugin-tailwindcss sorts them into Tailwind's own order. In v4 it needs to know your CSS file, so it can see custom theme names. My configuration and results:

// .prettierrc { "plugins": ["prettier-plugin-tailwindcss"], "tailwindStylesheet": "./app.css", "tailwindFunctions": ["cn", "cva"] } /* app.css */ @import "tailwindcss"; @theme { --color-brand: oklch(55% .2 260); }
before: px-4 hover:bg-brand text-white md:px-8 bg-brand rounded-md flex font-bold p-2 custom-thing dark:bg-black after: custom-thing flex rounded-md bg-brand p-2 px-4 font-bold text-white hover:bg-brand md:px-8 dark:bg-black without "tailwindStylesheet" (same input): hover:bg-brand bg-brand custom-thing flex rounded-md p-2 px-4 font-bold text-white md:px-8 dark:bg-black

The order is: classes Tailwind does not know first (custom-thing), then utilities in Tailwind's order, then variants (hover:, md:, dark:) last. Without the stylesheet setting the plugin did not know bg-brand (a theme name) and put it in the unknown group at the front, so a missing setting changes the sort silently. For helper functions:

export const B = () => <div className={cn("p-4 flex mt-2 items-center", x && "text-white bg-red-500 p-1")}>b</div>; export const v = cva("p-4 flex mt-2", { variants: { s: { a: "text-lg px-2 flex" } } });
with "tailwindFunctions": ["cn", "cva"]: className={cn("mt-2 flex items-center p-4", x && "bg-red-500 p-1 text-white")} cva("mt-2 flex p-4", { variants: { s: { a: "flex px-2 text-lg" } } }) without it: className={cn("mt-2 flex items-center p-4", x && "bg-red-500 p-1 text-white")} (still sorted: it is inside className) cva("p-4 flex mt-2", { variants: { s: { a: "text-lg px-2 flex" } } }) (NOT sorted)

Strings inside a className attribute are sorted without any setting, even inside cn(...). Strings in a call that is not inside a class attribute, like the cva table, need tailwindFunctions.

Linting: Typos, Conflicts and Unknown Classes

A typo like bg-brandd builds without error and styles nothing (chapter 6). A linter can catch it. I ran two ESLint plugins on the same file with a conflict, a typo and an unknown class:

<div className="p-4 p-2 flex block text-red-500 bg-brandd unknown-thing mt-2"> // eslint-plugin-tailwindcss 4.4.0 settings: { tailwindcss: { cssConfigPath: './app.css' } } error 'p-4' conflicts with 'p-2' tailwindcss/no-contradicting-classname error 'flex' conflicts with 'block' tailwindcss/no-contradicting-classname warning Classname 'bg-brandd' is not a Tailwind CSS class tailwindcss/no-custom-classname warning Classname 'unknown-thing' is not a Tailwind CSS class tailwindcss/no-custom-classname warning Invalid Tailwind CSS classnames order tailwindcss/classnames-order (several) // eslint-plugin-better-tailwindcss 4.9.0 settings: { 'better-tailwindcss': { entryPoint: 'app.css' } } error Conflicting class detected: "p-4" and "p-2" apply the same CSS properties: "padding" error Conflicting class detected: "flex" and "block" apply the same CSS properties: "display" error Unknown class detected: bg-brandd error Unknown class detected: unknown-thing warning Incorrect class order ... warning Incorrect line wrapping ...

Both work with v4 and both read your CSS file so that bg-brand counts as known (the typo bg-brandd does not). They differ in what they report as an error: the first treats an unknown class as a warning (it may be your own class), the second as an error. Plugins change faster than Tailwind: eslint-plugin-tailwindcss 4.4.0 exposes its config as configs.recommended only; the "flat/recommended" name from its README example (written as flat/recommended || recommended) did not exist in this version.

Do the tools agree with each other? I formatted a line with Prettier and linted the result. eslint-plugin-tailwindcss reported nothing. eslint-plugin-better-tailwindcss reported its order rule satisfied, but its line-wrapping rule wanted the classes split over several lines, which fights Prettier. If you use Prettier, turn that rule off ("better-tailwindcss/enforce-consistent-line-wrapping": "off"; I did not run that exact setting) and let one tool own each job.

Editor settings (VS Code)

Not run here; these come from the Tailwind CSS IntelliSense extension's README. "files.associations": {"*.css": "tailwindcss"} opens CSS files in Tailwind mode. tailwindCSS.classAttributes (default class, className, ngClass, class:list) and tailwindCSS.classFunctions say where to offer completions; add cn and cva there as well as in Prettier. tailwindCSS.lint.cssConflict (default warning) flags classes that set the same property, and tailwindCSS.lint.suggestCanonicalClasses flags class names not in their shortest form.

Long Class Lists

A button with fifteen classes repeated on every button is a maintenance problem, not a size problem. I built 200 buttons with the same 15 classes inline, and again with a .btn class made by @apply inside @layer components:

inline (15 classes on each of 200 buttons) HTML 48,290 B (gzip 824) CSS 7,204 B (gzip 2,179) total gzip 3,003 B .btn via @apply HTML 7,690 B (gzip 547) CSS 6,984 B (gzip 2,101) total gzip 2,648 B

Repetition compresses almost to nothing, so the transferred difference was 355 bytes (12%) on 200 identical buttons. The real argument for extracting is that you change the button in one place. The options, in the order I would try them:

OptionUse whenCost
A component (React, Vue, Astro, a template partial)the same markup repeatsnone beyond the component; the classes stay in one file (chapter 6)
Variants with cvathe repeated thing has optionsa dependency; unknown values fail silently without types
@apply in @layer componentsplain HTML or a CMS with no component systema second place to look; utilities still override it because of layer order
Leave it longthe list is used oncenothing; a long list used once is fine

Catching Visual Changes

A class change can break a page without any test failing. The cheapest check is a screenshot comparison. I rendered a small card page in headless Chrome (--screenshot), took screenshots of the same page, and compared them with pixelmatch:

v1a vs v1b the same page screenshotted twice (new browser profile each time) 0 differing pixels, identical file hashes v1a vs v2 button bg-blue-600 -> bg-blue-500 and py-2 -> py-2.5 3,253 pixels v1a vs v3 text-slate-600 -> text-slate-500 on one paragraph pixelmatch threshold 0.1 (default) 185 pixels pixelmatch threshold 0 346 pixels

On one machine with the same browser, two runs of an unchanged page were identical, so any difference was a real change. A one-shade colour change was caught at both thresholds. Two cautions that I did not test: screenshots from a different operating system or browser version will differ in text rendering, so compare only against screenshots from the same environment (usually your CI image), and a threshold that is too loose will hide small changes like the shade above. Tools such as Playwright's screenshot comparison automate this; I did not run it.

A Review Checklist for Tailwind Markup

  • Complete class names. No 'bg-' + color (chapter 1). Variant maps contain whole classes.
  • Conflicts. No two classes setting the same property on one element; component className goes through cn (chapter 6), and a hover: counterpart is overridden too.
  • Tokens, not literals. A colour or size used in more than one place is a theme token (chapter 2), not bg-[#3b82f6] repeated.
  • State uses the right attribute. data-* or aria-* variants match the library's real attributes (chapter 4 and 6); grep the built CSS if unsure.
  • Sorted and linted. Prettier and ESLint pass with no per-file exceptions.
  • Length. A class list repeated three times becomes a component or variant.
  • Browser targets. No bleeding-edge utility the targets lack.
  • Visual check. The screenshot diff for changed components was looked at, not just passed.
Mistakes while preparing this chapter
1. My first size comparison gave identical results for three different projects. I ran the CLI from the parent folder, so the scanner (which starts at the working directory) read all three. How to avoid it: when results that should differ come out the same, check what was scanned; this is the chapter 1 lesson again.

2. I timed npx, not the build. Every run took about 3.5 seconds; the build itself took 120 to 330 ms. How to avoid it: time the program directly and compare with its own timer.

3. --watch exited immediately without watching. In a non-interactive shell it needs --watch=always. How to avoid it: look for the first "Done" line in the log before editing any file.

4. A test that could not show a difference. I limited the scan to a glob that, as it happened, matched 111 of 500 files, which together contained every one of the 150 classes, so the size was unchanged. I dropped it. How to avoid it: work out in advance what result would prove the claim, and build the input so that result is possible.

5. I copied the plugin's README config and it failed (configs["flat/recommended"] is not iterable). How to avoid it: print Object.keys(plugin.configs) for the version you installed.

Hands-On Exercises

Exercise 1

Generate three 500-file projects with different numbers of distinct classes (and one with variant prefixes), build each from inside its own folder, and report plain, minified and gzipped sizes. Then time the same build with npx and by calling the CLI directly, and measure one --watch=always rebuild.

๐Ÿ“„ View solution
Exercise 2

Configure Prettier with the Tailwind plugin and ESLint with one of the two Tailwind plugins. Run them on a file with a conflict, a typo and an unsorted list, show the report, fix it, and show that Prettier and ESLint then agree. Include a custom theme colour and a cva call.

๐Ÿ“„ View solution
Exercise 3

Screenshot a small page with headless Chrome, change one class, screenshot again and count differing pixels with pixelmatch. Include the unchanged-twice control and compare two thresholds on a subtle colour change.

๐Ÿ“„ View solution

Chapter 7 Quick Reference

  • Output size follows distinct class-plus-variant combinations, not files or repeats: 150 classes 13.6 KB, 785 classes 41.7 KB, 4,513 classes (with variants) 239 KB minified; gzipped 3.1, 7.1 and 29.0 KB
  • theme(static) doubled the 150-class output; dropping preflight saved 3.6 KB but removes the reset; --minify shrinks, --optimize does not
  • Time the program, not npx. Cold build of 500 files: 120 to 330 ms; watch edits mostly under 100 ms; CSS edits about a cold build. Use --watch=always in scripts
  • v4 needs Chrome 111, Safari 16.4, Firefox 128 (Tailwind's compatibility page); the output uses layers, @property, color-mix(), oklch()
  • Prettier: plugins, tailwindStylesheet (needed to know theme names), tailwindFunctions (needed for cva); strings inside className are sorted anyway
  • ESLint: eslint-plugin-tailwindcss (cssConfigPath) or eslint-plugin-better-tailwindcss (entryPoint); both caught conflicts and the typo. Turn off the second one's line-wrapping rule if you use Prettier
  • Extract repeated markup into components or variants for maintenance; the transfer saving was 12% gzipped
  • Screenshots from the same browser and machine were byte-identical, so a pixel diff is meaningful; keep references per environment
Coming next
Chapter 8 migrates a project from Tailwind v3 to v4: the upgrade tool, the new import line, moving the JavaScript config into CSS, renamed and removed utilities, changed defaults, running both versions side by side, and a worked migration of a small project. Start with Tailwind CSS Intermediate/Advanced 8.