Tailwind CSS Handbook v4.3

Utility-First CSS

Why composing single-purpose classes scales better than component CSS, how to manage duplication, and where the utility-first approach stops paying off.

Utility-first means styling with many small single-purpose classes instead of few large semantic ones. It is the one idea the entire framework rests on.

Overview#

Compare the two approaches on the same component:

styles.css
.notification {
  display: flex;
  max-width: 24rem;
  padding: 1.5rem;
  border-radius: 0.5rem;
  background-color: #fff;
  box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}

.notification__body {
  margin-left: 1.5rem;
}

.notification__title {
  font-size: 1.25rem;
  font-weight: 500;
  color: #111827;
}
index.html
<div class="notification">
  <img class="notification__logo" src="/logo.svg" alt="">
  <div class="notification__body">
    <h4 class="notification__title">ChitChat</h4>
    <p class="notification__text">You have a new message!</p>
  </div>
</div>

The utility version is one file instead of two, invents no names, and cannot break another component when edited.

Why it exists#

  • Speed. No context switching between markup and stylesheet, and no time spent naming things.
  • Safety. Changing a utility class affects exactly one element. Changing .notification affects every notification, including ones you forgot about.
  • Maintainability. The styles live where the markup lives, so you can read a component without hunting.
  • Portability. Copying markup copies its styling.
  • CSS that plateaus. Utilities are reused, so the stylesheet stops growing while features keep being added.

Utilities also beat inline styles in three ways that matter:

Inline stylesUtility classes
Design constraintsNone — any valueGenerated from your theme scale
StatesImpossiblehover:, focus:, disabled:
Media queriesImpossiblesm:, md:, dark:, @container

Managing duplication#

The usual objection is repetition. There are three answers, in order of preference.

1. Loops — for repeated elements#

If the markup already renders in a loop, the class list is written once.

JSX
{contributors.map((user) => (
  <img
    key={user.handle}
    className="inline-block size-12 rounded-full ring-2 ring-white"
    src={user.avatarUrl}
    alt={user.handle}
  />
))}

2. Components — for anything reusable#

This is the recommended answer for anything more complex than a single element. The component becomes the single source of truth.

Button.jsx
export function Button({ variant = 'primary', ...props }) {
  const styles = {
    primary: 'bg-indigo-600 text-white hover:bg-indigo-500',
    secondary: 'bg-white text-gray-900 ring-1 ring-gray-300 hover:bg-gray-50',
  }

  return (
    <button
      className={`rounded-md px-4 py-2 text-sm font-semibold transition ${styles[variant]}`}
      {...props}
    />
  )
}

Template partials work the same way in Blade, ERB, Twig or Nunjucks.

3. Custom CSS — when a component is too heavy#

For a single element repeated across unrelated templates, hand-written CSS built from theme variables is reasonable:

CSS
@import "tailwindcss";

@layer components {
  .btn-primary {
    border-radius: calc(infinity * 1px);
    background-color: var(--color-violet-500);
    padding-inline: --spacing(5);
    padding-block: --spacing(2);
    font-weight: var(--font-weight-semibold);
    color: var(--color-white);
    box-shadow: var(--shadow-md);
  }
}

Because it lives in @layer components, utilities still override it: <div class="btn-primary rounded-none"> works.

When not to reach for utilities#

  • Content you do not author. Markdown or CMS output has no classes to attach. Use the typography plugin or scoped CSS.
  • Deeply repeated primitives with no component layer. A static site with no templating engine will genuinely duplicate. @apply or @layer components is the pragmatic answer.
  • Complex keyframes and multi-step animation. Define them in CSS; expose them through the --animate-* namespace.
  • Print stylesheets and email. Different constraints entirely.

Common mistakes#

Reaching for @apply first#

Tempting, but it undoes the benefit
.card {
  @apply rounded-xl border p-6 shadow-sm;
}

You are back to naming things, back to two files, and back to not knowing what breaks when you edit .card. @apply exists for narrow cases — see Custom Utilities.

Building class names dynamically#

JSX
// Produces nothing — Tailwind never sees the full string
<div className={`bg-${color}-500`} />

// Works — complete class names appear in the source
const BG = { blue: 'bg-blue-500', red: 'bg-red-500' }
<div className={BG[color]} />

Abandoning semantic HTML#

Utilities describe appearance; the element still carries meaning. <div class="font-bold text-2xl"> is not a heading. Use <h2>, <button>, <nav>, <table> — see Forms for why it matters to assistive technology.

Fighting long class lists by hand-sorting#

Install prettier-plugin-tailwindcss and stop thinking about it.

Tips#

  • Order does not affect the cascade — Tailwind's layers handle specificity — so sort for readability.
  • When a value repeats across components, promote it to a theme token rather than repeating an arbitrary value.
  • Long class lists are a signal to check whether the component is doing too much, not whether the CSS is.