Is Tailwind CSS Worth It? 881 Class Attributes, One 78 KB File
Answered with three measurements instead of taste: 133 hand-written lines, 14 KB gzipped across 488 pages, and the AA contrast bug the token scale made easy to ship.
Tailwind is worth it when your project has more components than stylesheets and more contributors than designers. Measured on this storefront: 881 class attributes across 92 components produce one 78 KB stylesheet, and only 133 lines of CSS were written by hand. The costs are real but small, and this post lists them by name.
Make the question answerable
"Is Tailwind worth it" is usually argued on taste — ugly markup versus semantic class names. That argument has no resolution, so replace it with three numbers you can measure on any codebase after the fact:
- How much CSS did you write by hand?
- How much CSS ships to the browser?
- What did the framework cost you that plain CSS would not have?
This storefront is a 139-file, 92-component Next.js app with 26 static routes and 488 prerendered pages. Here are its three answers.
1. Hand-written CSS: 133 lines
The entire authored stylesheet is one file:
$ wc -l src/app/globals.css
133 src/app/globals.css
That is not a fragment — it is everything. It holds the Tailwind v4 @theme block (41 design tokens: the brand primary scale, the TailAdmin gray scale, semantic text-color/title-color/stroke/success tokens), the responsive .container rule, the marquee keyframes, and a .no-scrollbar utility.
Note what is absent: there is no tailwind.config.js. Tailwind v4 is CSS-first, so the configuration is the stylesheet — one @import, one PostCSS plugin, and a @theme block. Most setup tutorials still open with npx tailwindcss init, a command that no longer exists.
2. Shipped CSS: one file, 78 KB, for 488 pages
$ stat -c '%s %n' .next/static/chunks/*.css
78049 .next/static/chunks/3eehyjxxz6pqi.css
$ gzip -c .next/static/chunks/3eehyjxxz6pqi.css | wc -c
13958
13,958 bytes over the wire, once, for the entire site. Every route — home, the 111 product pages, the 170 blog posts, the pSEO hubs — shares that one cached file.
This is the mechanical argument for utility CSS, and it is the one that survives scrutiny. The stylesheet does not grow when you add a page, because a page adds no new CSS; it reuses classes already in the file. A component-scoped or BEM approach grows roughly with the number of components, since each new one contributes its own rules. At 92 components the difference is already decisive, and this codebase adds components continuously.
The corollary is the honest cost: utilities are only cheap in bulk. On a five-page brochure site, 14 KB of utilities to style five pages is worse than 3 KB of hand-written CSS. Tailwind's economics need scale to work, and "worth it" genuinely depends on having it.
3. What it actually cost
Three costs, all real, all encountered in this repository.
A contrast defect the token scale made easy to ship. BlogTag renders the small cluster pill on every blog card. It used text-primary on bg-primary-25 — two tokens from the same scale, which looks obviously safe. At 12px it measures 4.49:1, missing WCAG AA by one hundredth:
/**
* `text-primary-600`, not `text-primary`: at 12px on `bg-primary-25` the brand
* blue measures 4.49:1, which misses AA by a hundredth. One shade darker is the
* same fix the other brand-tinted small text on the site already carries.
*/
This is a genuine Tailwind-shaped hazard. A numbered scale invites you to pick shades by eye — primary on primary-25 reads as high contrast — and nothing in the utility layer checks the ratio. Hand-written CSS does not prevent the bug, but it does tend to force the question, because you type the hex values.
The @theme hot-reload trap. Tailwind v4 with Turbopack does not hot-reload @theme changes. Add a token, use the new bg-* utility, and it silently does not generate — no error, no class. The fix is to restart npm run dev, which is obvious once you know and costs half an hour when you do not.
Class ordering needs a tool. With 881 class attributes, hand-ordered utility strings make diffs unreadable. This repo runs prettier-plugin-tailwindcss, pointed at the v4 stylesheet:
{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app/globals.css"
}
That is a hard requirement at this size, not a nicety. Budget for it.
The repetition objection, answered by where the reuse lives
The most common argument against utilities is that you repeat yourself: the same twelve classes on every button, forever. It is a fair objection with a specific answer, and the answer is not @apply.
Across the whole stylesheet, @apply appears exactly twice — both in globals.css, both on the body element:
@apply bg-white font-sans text-base font-normal text-gray-700;
/* and, under the dark variant: */
@apply bg-gray-900 text-gray-300;
Nothing else reaches for it. That is deliberate. @apply compiles a utility set into a named CSS class, which recreates the exact naming problem utilities removed — the class is defined off-file, and "what does this render" stops being answerable from the markup.
The reuse instead lives in components. The Button atom holds its class list once:
const base =
"inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-base font-medium shadow-xs duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";
const variants: Record<ButtonVariant, string> = {
primary: "bg-primary text-white hover:bg-primary-600",
outline:
"border border-stroke-tertiary bg-white text-text-color hover:bg-gray-50 hover:text-gray-800",
dark: "bg-gray-900 text-white hover:bg-gray-800",
};
Every call site writes <Button variant="primary">, not the fifteen utilities. So the 881 class attributes are not 881 hand-maintained strings — they are concentrated in 92 components, and the shared ones are written once each. The unit of reuse moved from a CSS block to a component prop, which is why the CSS-level tool went nearly unused.
The rule this codebase follows: extract a component the third time a class string repeats. Never extract a CSS class.
The trade, in one table
| Tailwind here | Plain CSS / CSS Modules | |
|---|---|---|
| Hand-written CSS | 133 lines, one file | Grows with component count |
| Shipped CSS | 78 KB raw / 14 KB gzipped, flat | Grows with component count |
| Naming decisions | None | One per element |
| Dead-CSS risk | Near zero — unused utilities never generate | Real, and grows with age |
| Design consistency | Enforced by the token scale | Enforced by convention and review |
| Contrast safety | No help; scale invites bad pairs | No help; hex values prompt the check |
| Readability of markup | Long class strings | Clean markup, indirection to a stylesheet |
| Tooling required | Prettier plugin, effectively mandatory | Optional |
| Best at | Many components, few designers | Few components, heavy custom design |
When it is not worth it
Three honest cases:
- Small static sites. Under ~10 components the fixed cost of the utility layer is not amortised.
- Design-led work with little repetition. If nearly every element is bespoke, you get the class-string cost and little of the reuse benefit.
- Teams that need markup to stay legible to non-frontend contributors. Long class strings are a genuine readability tax; that this codebase pays it willingly does not mean yours should.
None of those describe a component-heavy product app, which is why the answer here is yes.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
A new bg-*/text-* utility from a token does nothing | @theme edits do not hot-reload under Turbopack | Restart npm run dev |
npx tailwindcss init fails | The command was removed in v4 | Configure in CSS: @import "tailwindcss" plus a @theme block |
| Utility classes are not generated at all in some file | Tailwind only sees literal strings in source; a computed `bg-${color}` is invisible to it | Use whole class names in a lookup map, never string interpolation |
| Class strings reorder noisily in every diff | No canonical ordering | Add prettier-plugin-tailwindcss with tailwindStylesheet set |
| Brand-tinted small text fails a contrast audit | Two shades from the same scale can still miss AA | Drop one step darker (primary → primary-600) and re-measure at the real font size |
Frequently asked questions
Does Tailwind make the HTML bigger? Yes, and it is the trade. Class attributes are repeated per element rather than referenced once, so markup grows while the stylesheet stays flat. On a prerendered site that HTML compresses extremely well — repeated class strings are ideal gzip input — and it is served from a CDN, so the shipped cost is far smaller than the source suggests.
Is 14 KB of gzipped CSS good? For 488 pages, yes. The number to compare it against is not zero but what an equivalent hand-written stylesheet would weigh across 92 components, and the meaningful property is that this one does not grow as pages are added.
Do I still need to know CSS? Completely. Utilities are a naming layer over CSS properties, not a replacement for understanding them — the 4.49:1 contrast bug above is a CSS problem that no amount of Tailwind fluency prevents. Flexbox, grid, stacking contexts and specificity are all still yours to know.
Is v4 worth migrating to from v3?
The CSS-first config is a real simplification, and it deletes the JS config file and content globs entirely. The caveat is the tooling edges — the @theme hot-reload behaviour above being the one that cost time here.
Templates in this post
ASoc Quest (a dark-theme games-storefront landing page), ASoc Quill (a lightweight publication template) and ASoc Rally (an AI CRM marketing site) are all built on the same token-driven Tailwind setup described here — one @theme block, no JS config.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the underlying mechanics, see Tailwind vs. CSS, What Is a Tailwind Class? and Tailwind Design Tokens.
