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:
.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;
}<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><div class="flex max-w-sm items-center gap-6 rounded-xl bg-white p-6 shadow-lg">
<img class="size-12 shrink-0" src="/logo.svg" alt="">
<div>
<h4 class="text-xl font-medium text-gray-900">ChitChat</h4>
<p class="text-gray-500">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
.notificationaffects 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 styles | Utility classes | |
|---|---|---|
| Design constraints | None — any value | Generated from your theme scale |
| States | Impossible | hover:, focus:, disabled: |
| Media queries | Impossible | sm:, 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.
{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.
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:
@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.
@applyor@layer componentsis 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#
.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#
// 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.
Related topics#
- Your First Utilities — reading class names
- Theme & Design Tokens — where the values come from
- Custom Utilities — adding your own
- Components & Patterns — worked examples