
Utility-First CSS: Pros and Cons of the Tailwind Approach
Few CSS topics start arguments as reliably as utility-first CSS. To some developers, a button with fifteen classes in its markup looks like a return to inline styles and everything we learned to avoid. To others, it's the first approach that ever made CSS feel fast and maintainable at scale. Tailwind CSS brought the idea into the mainstream, and today it's one of the most popular ways to style web applications.
Full disclosure: this very site is built with Tailwind. But I've also maintained large BEM codebases and I've seen utility-first go wrong. This article is an honest look at both sides, with code, so you can decide what fits your project.
What Utility-First Means
In a traditional, component-first approach, you write a semantic class name in the HTML and describe its styles in a stylesheet:
<button class="btn-primary">Save changes</button>
.btn-primary {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: #4f46e5;
color: #ffffff;
font-weight: 600;
}
.btn-primary:hover {
background: #4338ca;
}
In a utility-first approach, you compose the design directly in the markup from small, single-purpose classes:
<button
class="inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 font-semibold text-white hover:bg-indigo-700"
>
Save changes
</button>
Each class does one thing: px-4 sets horizontal padding, rounded-lg sets the border radius, and hover:bg-indigo-700 changes the background on hover. The framework generates only the classes you actually use.
Utility classes themselves aren't new. Most CSS codebases have had .text-center or .mt-0 for years. What's different is using utilities as the primary way of styling, rather than an occasional escape hatch.
How Tailwind Works Today
Tailwind scans your source files for class names and generates CSS only for the ones it finds. Recent major versions moved configuration into CSS itself. Your design tokens live in a @theme block, and they become both utilities and native custom properties:
@import "tailwindcss";
@theme {
--color-brand-500: oklch(0.62 0.19 265);
--color-brand-600: oklch(0.55 0.2 265);
--font-display: "Inter", system-ui, sans-serif;
--breakpoint-3xl: 120rem;
}
Now bg-brand-600, font-display, and 3xl:grid-cols-6 all exist, and var(--color-brand-600) is available in any custom CSS you write. This matters, because it means the utilities and your hand-written CSS share the same source of truth.
The Pros
1. You Stop Naming Things
In component-first CSS, every wrapper needs a name. Is it .card__inner, .card__content, or .card__body-wrapper? Utility-first removes that decision for the dozens of small structural elements that don't deserve one. It sounds minor, but in practice it removes a lot of friction.
2. Styles Are Local and Safe to Change
When styles live on the element, changing them only affects that element. There's no risk that editing .card__title breaks a page you forgot about. Deleting a component deletes its styles too, so there's no dead CSS accumulating in a stylesheet that nobody dares to prune.
3. The CSS Bundle Stops Growing
In a traditional codebase, CSS grows with every feature. With utilities, the set of classes eventually plateaus. There are only so many padding values and colors in a design system. After a point, new features mostly reuse existing classes, so the stylesheet barely grows.
4. Design Constraints Built In
Utilities come from a scale: spacing steps, a color palette, type sizes. Developers pick p-4 or p-6, not padding: 17px. That nudges everyone toward consistency without a separate design-token review.
5. Fast Iteration
You can build and adjust a layout without switching files. Responsive and state variants sit right next to the base style:
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<article
class="rounded-xl border border-slate-200 p-6 shadow-sm transition hover:shadow-md focus-within:ring-2 focus-within:ring-indigo-500"
>
<h3 class="text-lg font-semibold text-slate-900">Usage reports</h3>
<p class="mt-2 text-sm text-slate-600">
Daily breakdowns of active seats and API calls.
</p>
</article>
</div>
6. It Pairs Well with Component Frameworks
The biggest criticism of utilities, repetition, mostly disappears in React, Vue, Svelte, or any templating system with components. You write the long class list once, inside a Button component, and reuse the component everywhere.
export function Button({ variant = "primary", className = "", ...props }) {
const base =
"inline-flex items-center gap-2 rounded-lg px-4 py-2 font-semibold focus-visible:outline-2 focus-visible:outline-offset-2";
const variants = {
primary:
"bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:outline-indigo-600",
secondary:
"bg-white text-slate-900 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-slate-600",
};
return (
<button
className={`${base} ${variants[variant]} ${className}`}
{...props}
/>
);
}
The Cons
1. Noisy Markup
There's no getting around it: long class lists make HTML harder to scan. A complex component can carry thirty classes on a single element, and the structure of the page gets buried in styling details. Editor tooling (autocomplete, hover previews, class sorting with the official Prettier plugin) helps, but it doesn't make the markup less dense.
2. Repetition Without Components
If your project doesn't have a component system, such as a server-rendered CMS theme, a static site with hand-written HTML, or content from a WYSIWYG editor, you end up copying the same class strings across many templates. Change the button design and you're doing find-and-replace across dozens of files.
3. Learning a Second Vocabulary
Developers who know CSS still need to learn Tailwind's names: justify-between, tracking-tight, inset-x-0, size-10. It's learnable, and it maps closely to CSS, but it's a layer of indirection. There's also a real risk that newer developers learn Tailwind instead of CSS, and struggle when they hit something the utilities don't cover.
4. Complex Selectors Get Awkward
Utilities are great for "this element looks like this". They're clumsier for relationships between elements. Tailwind supports many of them through variants like group-hover:, peer-checked:, has-[:checked]:, and arbitrary variants like [&>li]:mt-2, but past a certain point the class names become harder to read than the CSS they replace:
<ul
class="[&>li:not(:last-child)]:border-b [&>li]:py-3 [&>li:first-child]:pt-0"
>
<li>First</li>
<li>Second</li>
<li>Third</li>
</ul>
For cases like this, a few lines of regular CSS are clearer.
5. Styling Content You Don't Control
Markdown output, CMS rich text, and third-party widgets don't have your utility classes on them. You need another approach, such as the official Typography plugin's prose classes, or a hand-written stylesheet for that content.
6. Build Tooling Dependency
Tailwind needs a build step to scan your files and generate CSS. Classes built dynamically at runtime, like string concatenation of bg- plus a variable color name, won't be detected:
// Won't work: the scanner never sees the full class name
<div className={`bg-${color}-500`} />;
// Works: complete class names appear in the source
const colorClasses = { red: "bg-red-500", green: "bg-green-500" };
<div className={colorClasses[color]} />;
It's a common source of "my class isn't working" bugs.
7. Separation of Concerns, Revisited
The classic argument is that HTML should describe content and CSS should describe presentation. Utility-first advocates counter that in component-based apps, the component is the unit of concern, and splitting its markup and styles across files was never real separation. Both positions have merit. If your HTML needs to be restyled completely without touching the markup, as in the CSS Zen Garden ideal, utility-first is the wrong tool.
Finding a Middle Ground
Most successful Tailwind projects I've seen aren't 100% utilities. They mix approaches deliberately.
Extract Components in Your Framework, Not in CSS
Tailwind offers @apply to bundle utilities into a class:
.btn {
@apply inline-flex items-center rounded-lg px-4 py-2 font-semibold;
}
It's handy for the occasional case, like styling markup you can't add classes to, but using it everywhere recreates component-first CSS with extra steps. Tailwind's own docs recommend extracting template components first.
Write Real CSS When It's Clearer
Keep a small stylesheet for things utilities handle poorly: complex selectors, animations with multiple keyframes, CMS content, and third-party overrides. Since Tailwind's theme values are native custom properties, custom CSS stays on-system:
@layer components {
.prose-content > * + * {
margin-block-start: calc(
var(--spacing) * 4
); /* same as the 4 step in the spacing scale */
}
.prose-content a {
color: var(--color-brand-600);
text-underline-offset: 0.2em;
}
}
Use Semantic Tokens
Instead of scattering bg-indigo-600 everywhere, define semantic tokens such as --color-primary or --color-surface. When the brand color changes, or you add dark mode, you change a token, not a thousand class names.
When Utility-First Fits
- Component-based applications (React, Vue, Svelte, Astro components, Rails ViewComponents)
- Teams that prototype and iterate on UI quickly
- Projects with a well-defined design system that maps onto a scale
- Codebases where CSS has historically rotted and grown out of control
When It Doesn't
- Sites built mainly from hand-written HTML with no component layer
- Heavily themed products where the same markup must be restyled completely
- Content-heavy sites where most styling targets CMS output
- Teams without buy-in, since a half-adopted utility system is worse than either approach done well
Conclusion
Utility-first CSS trades noisy markup for locality, consistency, and a CSS bundle that stops growing. It shines in component-based applications and struggles where there's no component layer or where most content arrives as unstyled HTML. It isn't a replacement for knowing CSS. The best Tailwind developers I know are strong CSS developers who reach for a hand-written rule whenever it's clearer.
If you're curious, try it on a small, self-contained project rather than an argument. Build one real feature with utilities, then judge it on how easy it is to change a month later. That's the test that matters.


