The Utility-First Approach: What Tailwind Solves, What It Costs and When Not to Use It
Utility-first writes styles inside the component with small, token-bound classes; the CSS grows with the number of distinct classes rather than components, and the price is long class lists and a learning curve. We measure the CSS and HTML cost of both models on three Bookstore components, solve repetition with a Razor partial, and show why @apply is the last resort, what Preflight does to CMS content and why unlayered legacy CSS overrides utilities.
Version note: The examples in this post were run with Tailwind CSS 4.3.5 (@tailwindcss/cli), .NET 10 and Playwright 1.65 (Chromium 158).
Guess First
For Bookstore, the online bookstore used as the shared example across this site's series, we wrote three small pieces of UI: the book card in the catalog, the book line in the cart and the order summary on the checkout page. We wrote each one twice: once the classic way, with meaningful class names and a separate CSS file, and once with Tailwind's utility classes. Then we measured the CSS at every step. Here is the first step:
step 1 (+book-card)
semantic CSS : 724 B, gzip 365 B
utility CSS : 4170 B, gzip 1231 B (29 distinct classes)
The CSS Tailwind generates is more than five times the size of the hand-written CSS. Once the cart line and the order summary are added, how do the two files grow? With the third component, how many bytes does the hand-written CSS gain, and how many does Tailwind's?
This is the first post of the Tailwind CSS series. Before installing anything we talk about the approach itself: what utility-first solves, what it asks for in return, and which projects are better off without it. The measurements come from the scripts in the lab folder that ships with this post; installing Tailwind and the input.css file get their own post next.
Two Models: Meaningful Class Names and Utility Classes
In the classic model the HTML describes content and the look lives in a separate file. With BEM, the Block Element Modifier naming convention that builds class names from blocks, elements and states, the book card looks like this:
<article class="book-card">
<img class="book-card__cover" src="../../covers/dune.svg" alt="Cover of Dune" width="120" height="180">
<h3 class="book-card__title">Dune</h3>
<p class="book-card__author">Frank Herbert</p>
<div class="book-card__footer">
<span class="book-card__price">349.00 TRY</span>
<button class="book-card__button">Add to cart</button>
</div>
</article>
.book-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
background: #fff;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.book-card__title {
font-size: 1rem;
font-weight: 600;
color: #0f172a;
}
/* ... six more rules for the cover, author, footer, price and button */
In the utility-first model the same card lives in a single file. Each class does one job and its name says what that job is:
<article class="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<img class="aspect-2/3 w-full rounded-md object-cover" src="../../covers/dune.svg" alt="Cover of Dune" width="120" height="180">
<h3 class="text-base font-semibold text-slate-900">Dune</h3>
<p class="text-sm text-slate-600">Frank Herbert</p>
<div class="mt-auto flex items-center justify-between">
<span class="text-lg font-bold text-slate-900">349.00 TRY</span>
<button class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Add to cart</button>
</div>
</article>
At first glance this looks like inline styles with shorter names. The difference shows up in the CSS Tailwind generates:
@layer theme {
:root, :host {
--color-indigo-600: oklch(51.1% 0.262 276.966);
--color-slate-200: oklch(92.9% 0.013 255.508);
--spacing: 0.25rem;
--radius-xl: 0.75rem;
/* ... only the variables that are used */
}
}
@layer utilities {
.gap-3 {
gap: calc(var(--spacing) * 3);
}
.rounded-xl {
border-radius: var(--radius-xl);
}
.border-slate-200 {
border-color: var(--color-slate-200);
}
@media (hover: hover) {
.hover\:bg-indigo-500:hover {
background-color: var(--color-indigo-500);
}
}
}
Three things stand out. First, the values are not free-form: gap-3 means three steps of the --spacing scale and slate-200 is a color from the palette. Every utility is tied to a design token and everyone on the team picks from the same scale; it gets harder for one person to write 13px and another 0.8rem. Second, it does what inline styles cannot: hover: is a variant backed by a real :hover selector, and even a media query that limits it to devices that can hover. Third, the rules sit inside @layer utilities, which is also the source of one of the pitfalls below.
Both Sides of the Separation of Concerns Debate
The most common objection to utility-first is "styling has leaked into the HTML, separation of concerns is gone". The objection has a fair point: a page written with meaningful class names can be restyled from top to bottom by changing only the CSS file. CSS Zen Garden in the 2000s was the showcase of that idea: the same HTML, hundreds of designs.
Adam Wathan, the creator of Tailwind, asks the question from a different angle in his 2017 article "CSS Utility Classes and Separation of Concerns": not whether concerns are separated, but which way the dependency points. With meaningful class names the CSS depends on the HTML. .book-card__title is only useful in the book card; for the cart line you write a separate .cart-line__title and put the same three lines in it. The HTML can be restyled but the CSS cannot be reused. With utility classes the direction flips: the HTML depends on the CSS. The CSS is completely reusable, but you can no longer restyle the HTML from the CSS alone.
Which side wins depends on the project. In an Angular or Razor application where the design changes often and screens are built from components, the main work is adding new screens, so reusable CSS is worth more. On a publishing site where someone else produces the content and the same HTML is shown with different themes, restyleable HTML is worth more. Choosing utility-first does not mean giving up separation; it means drawing the line at the component boundary instead of the file type: the card's HTML and its look live in the same component, and the card is separate from the cart.
Measurement: How Fast Does the CSS Grow?
Back to the guess. The script adds one more component at each step. On the classic side it concatenates that component's CSS file and minifies it; on the Tailwind side it concatenates only the HTML and lets the Tailwind CLI generate the CSS:
// Tailwind side: only theme and utilities, no Preflight;
// source(none) turns automatic scanning off, only this step's HTML is scanned
writeFileSync(`${dir}/input.css`, [
"@layer theme, base, components, utilities;",
'@import "tailwindcss/theme.css" layer(theme);',
'@import "tailwindcss/utilities.css" layer(utilities) source(none);',
'@source "./index.html";',
].join("\n"));
const utility = build(`${dir}/input.css`, ".");
node scripts/01-growth.mjs
step 1 (+book-card)
semantic CSS : 724 B, gzip 365 B
utility CSS : 4170 B, gzip 1231 B (29 distinct classes)
step 2 (+cart-line)
semantic CSS : 1342 B, gzip 499 B
utility CSS : 4620 B, gzip 1351 B (38 distinct classes)
step 3 (+order-summary)
semantic CSS : 2234 B, gzip 600 B
utility CSS : 4770 B, gzip 1373 B (41 distinct classes)
In the third step the hand-written CSS grew by 892 bytes and Tailwind's CSS by 150. The order summary is made almost entirely of classes the card and the cart line already use; only three classes are new (pt-3, border-t, gap-2). On the classic side a new component means new names and new copies of the same declarations: .order-summary__checkout is line for line the same as .book-card__button.
The honest reading of this measurement is that on a three-component page Tailwind produces more CSS. Most of the roughly 4 KB starting cost does not come from classes but from @property definitions and theme variables; utilities such as shadows and borders talk to each other through these variables. The gain appears as the number of pages grows: the CSS grows with the number of distinct classes, not with the number of components, and that number nearly stops growing after a while. Hand-written CSS keeps growing with every new screen, and finding and deleting unused rules is left to you. In Tailwind, when you delete the last use of a class, it leaves the CSS on the next build.
The Cost: Long Class Lists and Repetition in HTML
The price for that gain is out in the open too. The "Add to cart" button has eight classes; the whole card has 29 distinct ones. In a code review, reading rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 and answering "why does this button look different from the other one?" takes longer than seeing .btn-primary. More classes also mean wider diffs.
The HTML grows as well. We measured a page that renders the same card 24 times with different books, both ways:
node scripts/01-html-size.mjs
semantic x1 : 422 B, gzip 239 B
semantic x24: 10357 B, gzip 603 B
utility x1 : 652 B, gzip 383 B
utility x24: 15877 B, gzip 763 B
The raw HTML is fifty percent bigger, but after gzip the difference is 160 bytes. The same class string repeats on every card, and compression catches that very well. The network cost is small; the real cost is in reading and maintenance.
The last cost is the learning curve. The team has to learn that justify-between is justify-content: space-between, that p-4 is 1rem, and that sm: does not mean "on small screens" but "at 640px and up". For someone who doesn't know the underlying CSS, Tailwind is a second language to learn; for someone who does, it is mostly a dictionary of abbreviations. That's why every post in this series also shows the CSS a utility produces; the mechanisms themselves are covered in the CSS3: Flexbox, Grid and Modern CSS and CSS Properties: A Hands-On Guide series.
Solving Repetition in a Component
In utility-first, the real answer to repetition is not CSS but the template. Bookstore's server-side UI, Bookstore.Web, is an ASP.NET Core MVC application. On its catalog page the card lives in a partial and is rendered in a loop:
@model BookCardViewModel
<article class="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<img class="aspect-2/3 w-full rounded-md object-cover" src="@Model.CoverUrl" alt="Cover of @Model.Title" width="120" height="180" loading="lazy">
<h3 class="text-base font-semibold text-slate-900">@Model.Title</h3>
<p class="text-sm text-slate-600">@Model.Author</p>
<div class="mt-auto flex items-center justify-between">
<span class="text-lg font-bold text-slate-900">@Model.Price.ToString("0.00", System.Globalization.CultureInfo.InvariantCulture) TRY</span>
<button class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500">Add to cart</button>
</div>
</article>
@model IReadOnlyList<BookCardViewModel>
<h1 class="mb-6 text-3xl font-bold">Catalog</h1>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
@foreach (BookCardViewModel book in Model)
{
<partial name="_BookCard" model="book" />
}
</div>
cd aspnet/Bookstore.Web
dotnet run
curl -s http://127.0.0.1:52861/ | grep -c '<article'
3
The card's class list is written once in the source; on the page it appears three times today and could appear thirty times tomorrow. To change a color you touch one file. In Angular a component does the same job; in the Angular post of this series we build the bookstore-web card with signals and dynamic classes. The Tailwind docs also suggest solving repetition first with a loop, then with multi-cursor editing inside one file, and with a component or partial when the repetition spans files. What the partial or component exposes is no longer a class name but a contract like BookCardViewModel; how components talk to the outside world is covered in the Component Architecture post of the UI Architecture Patterns series.
Why @apply Is the Last Resort
The first idea for escaping long class lists is @apply: copying utilities into a CSS rule.
@import "tailwindcss" source(none);
@layer components {
/* moving repetition into CSS: two names, the same declarations */
.btn-primary {
@apply rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500;
}
.btn-checkout {
@apply rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500;
}
}
npx tailwindcss -i 01/apply.css
@layer components {
.btn-primary {
border-radius: var(--radius-md);
background-color: var(--color-indigo-600);
padding-inline: calc(var(--spacing) * 3);
padding-block: calc(var(--spacing) * 1.5);
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
color: var(--color-white);
&:hover {
@media (hover: hover) {
background-color: var(--color-indigo-500);
}
}
}
.btn-checkout {
border-radius: var(--radius-md);
/* ... the same nine declarations as .btn-primary */
}
}
@apply copies declarations; every new name adds a new copy to the CSS. You hand back the gain from the measurement yourself: the CSS grows with the number of components again, naming comes back and the styling is split across two files again. On top of that comes a new question, "where is this class defined?", because .btn-primary is now neither in the HTML nor in the Tailwind docs.
@apply has a narrow but real place: HTML whose template you do not control, such as markup produced by a third-party widget, or a small, frequently used class for a single element. The Tailwind docs also accept a class written in @layer components with theme variables for small single-element things and recommend a partial for anything more complex. In Angular component styles @apply also needs a @reference line and runs Tailwind once more for every component file; we measure that in the Angular post.
When Not to Use It
In three situations it pays to think twice before choosing Tailwind.
The first is content-heavy pages. The reviews on Bookstore's blog come from a CMS as plain HTML; there are no classes in them, just h2, p and ul. Preflight, Tailwind's base reset, removes the browser defaults of these elements:
node scripts/01-preflight.mjs
h1 (class) : font-size 30px, weight 700, margin-bottom 0px, list-style disc
cms h2 : font-size 16px, weight 400, margin-bottom 0px, list-style disc
cms p : font-size 16px, weight 400, margin-bottom 0px, list-style disc
cms ul : font-size 16px, weight 400, margin-bottom 0px, list-style none
The h1 with classes is 30px and bold; the h2 from the CMS looks exactly like a plain paragraph: 16px, normal weight, no spacing. The list bullets are gone too. Since you cannot add classes to the body, utility-first does not help here. The answer is the prose class from the @tailwindcss/typography plugin: you wrap the body in a single class and the plugin styles the elements inside it typographically. This site's blog body is in the same situation; we cover the plugin in the everyday utility posts at the end of the series. If most of the page is CMS content, using Tailwind only for the frame and leaving the body to the plugin, or not using Tailwind at all, is cleaner.
The second is a large, working BEM or Sass codebase. Adding Tailwind next to it means maintaining two styling systems at once, and because of the cascade layer behavior in the pitfalls section, the old CSS usually overrides the new utilities. If the 7-1 and ITCSS structure from the style architecture post of the Sass/SCSS series is established, continuing it even for new screens is usually cheaper.
The third is an application that leans on Angular Material's styling system. Material components get their look from their own theme and token system; trying to change a mat-button with utility classes means competing with Material's own override mechanism. Using Tailwind next to Material for page layout, such as grids, spacing and card frames, is possible, but do not try to style the inside of Material components with Tailwind. We discuss this split separately in the Angular Material and CDK series.
In Production
The size of utility CSS depends on the number of classes in use, so that is the number to watch in production. Recording the size of the build output in every release is the easiest way to notice that someone generated hundreds of new classes with a loop of arbitrary values. Know the starting cost of a full build including Preflight, too: the file we built for the CMS page, with Preflight and a few utilities, is 4846 bytes minified and 1699 bytes after gzip.
The second topic is caching. Tailwind produces a single site.css that doesn't change from page to page, so long-lived caching and a hash in the file name (in ASP.NET Core, MapStaticAssets and asp-append-version) work very well. The class list in the HTML takes the place of page-specific CSS files.
The third is team agreement. If you don't agree on which values are tokens, when to extract a partial and in which order classes are written, utility-first falls apart too. We look at tools for sorting classes and merging conflicting ones in the maintenance post of this series.
Common Pitfalls
The trickiest pitfall shows up when you add Tailwind next to existing CSS. Tailwind's utilities live in @layer utilities; the old CSS is in no layer at all. In the cascade, unlayered rules beat layered rules regardless of specificity:
<style>
/* legacy CSS, not in any layer */
h3 { color: crimson; }
</style>
<h3 id="title" class="text-slate-900">Dune</h3>
node scripts/01-unlayered.mjs
h3.text-slate-900 color: rgb(220, 20, 60)
The class selector is more specific than the element selector, and crimson still won. The reason is the order from the cascade post of the CSS3 series: layers come before specificity. Moving the old CSS into a layer (with something like @import "legacy.css" layer(legacy);) or adding the ! modifier to utilities resolves the conflict; the first one is the lasting fix.
The second pitfall is collecting every repetition under a name with @apply. A month later you have a classic CSS file written in Tailwind syntax and you pay the cost of both models.
The third is building class names by concatenating strings: 'bg-' + color + '-600'. Tailwind scans source code as text and does not generate a utility whose full class name it never sees. We show this, and the fix, live in the next post.
The fourth is leaving the token scale. Tailwind allows arbitrary values like w-[37px]; but if you write every value that way you lose the consistency design tokens give you and end up with inline styles shaped like class names. We return to this in the variants post.
A Small Experiment
★☆☆ Add a fourth component to the 01-growth.mjs script: the "Similar books" row on the book detail page. First guess which classes will be new, then run the script and see how many bytes the utility CSS grows.
★★☆ Reduce the two @apply rules in 01/apply.css to a single .btn-primary class and add bg-emerald-600 next to it on the checkout button. Which color wins, and why? Look for the answer in the order of @layer components and @layer utilities.
★★★ Move the old rule in 01/unlayered.html into a @layer legacy block and declare the @layer order as legacy, theme, base, components, utilities. Measure how the Playwright output changes; then change the order to theme, base, components, utilities, legacy and measure again.
In the next post we install Tailwind v4: what the @import "tailwindcss" line brings in, how the Oxide engine scans source files, which folders to add or exclude with @source, and the build chain from input.css to site.css in Bookstore.Web, with --watch and --minify in Release.