Type something to search...
Utility-First CSS: Pros and Cons of the Tailwind Approach

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.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading