I've been writing CSS professionally for about eight years, and I went through the same arc most people do: vanilla CSS, then BEM, then Sass, then a couple years of CSS-in-JS, then Tailwind for everything. About six months ago I started ripping Tailwind out of a side project. Not because Tailwind is bad — it's genuinely useful — but because I'd lost the ability to think about my CSS. Every component looked like a barcode of utility classes, and refactoring meant copy-pasting strings between files.
If you've ever stared at a <div> with 27 utility classes and thought "I have no idea what this element actually is," this post is for you.
The problem: utility soup and cognitive overload
Here's the kind of markup I'm talking about. It's not contrived — I pulled this pattern out of a real component last week:
<button class="inline-flex items-center justify-center gap-2 rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50">
Save changes
</button>
There's nothing wrong with this. It works. It's even reasonably performant. But three things go sideways once your project gets past a few thousand lines:
- Pattern drift. You copy this button into another file, tweak two classes, and now you have two near-identical buttons that look 95% the same. Multiply by a year of development.
-
No semantic anchor. When a designer says "make the primary button slightly less aggressive," you have to grep for
bg-blue-600and pray nothing else uses it. - Refactoring tax. Renaming a color or spacing token means find-and-replace across templates, which is exactly the problem CSS variables were invented to solve.
The root cause isn't utility classes themselves. It's that utility-first frameworks push you to skip a step that used to be mandatory: naming things.
Why naming things matters more than you'd think
There's a famous Phil Karlton quote about how the two hard problems in computer science are cache invalidation and naming things. Utility CSS sort of dodges the naming problem by saying "you don't need names, just describe the visual." And for a single component in isolation, that's true.
But a codebase isn't a single component. It's a vocabulary. When you give an element a name like .card or .button--primary, you're declaring that this thing exists as a concept in your design system. That declaration is what lets future-you (or your teammate) reason about changes without reading every utility class one by one.
The fix: a layered CSS structure
Here's the structure I've landed on after migrating three projects away from utility-only CSS. It's nothing revolutionary — it's basically what CSS practitioners were doing in 2015 — but combined with modern features like custom properties and @layer, it holds up really well.
Step 1: Define your tokens as custom properties
Start with a single file that holds your design tokens. This is the foundation everything else builds on:
/* tokens.css */
:root {
/* Color scale — use semantic names, not hue names */
--color-bg: #ffffff;
--color-surface: #f7f7f8;
--color-text: #1a1a1a;
--color-text-muted: #6b6b6b;
--color-accent: #2563eb;
--color-accent-hover: #1d4ed8;
/* Spacing scale — pick one system and stick to it */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
/* Type scale */
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
}
/* Dark mode? Just override the tokens. */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f0f10;
--color-surface: #1a1a1c;
--color-text: #f5f5f5;
}
}
Notice the color names are semantic (--color-accent) not descriptive (--color-blue-600). This is the part Tailwind makes optional that I think should be mandatory. When you rebrand, you change one variable.
Step 2: Use CSS layers to control specificity
The @layer rule, supported in all modern browsers as of 2022, lets you explicitly order cascades. This is the feature that made me actually enjoy structured CSS again:
/* main.css */
@layer reset, base, components, utilities;
@import url('reset.css') layer(reset);
@import url('tokens.css'); /* tokens stay outside layers */
@import url('base.css') layer(base);
@import url('components.css') layer(components);
@import url('utilities.css') layer(utilities);
Now you don't have to worry about a .button rule getting accidentally overridden by some random .card .button selector elsewhere. Layer order beats specificity. It's a huge mental load off.
Step 3: Write component classes with a clear naming convention
I use a stripped-down BEM here. You don't have to use BEM specifically — pick whatever convention you like — but pick one and apply it consistently:
/* components.css */
@layer components {
.button {
/* Layout */
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
/* Visuals — all driven by tokens */
background: var(--color-surface);
color: var(--color-text);
border: 1px solid transparent;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
/* Interaction */
cursor: pointer;
transition: background-color 120ms ease;
}
.button--primary {
background: var(--color-accent);
color: white;
}
.button--primary:hover {
background: var(--color-accent-hover);
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
The markup goes back to being readable:
<button class="button button--primary">Save changes</button>
If you need a one-off tweak, that's where a small utility layer earns its keep — but the default path is to name the component.
Step 4: Keep a small utility layer for genuine one-offs
I didn't ditch utilities entirely. There are real cases where defining a class feels like overkill — a single margin tweak on one page, for instance. So I keep a tiny utility file:
@layer utilities {
.mt-4 { margin-top: var(--space-4); }
.text-muted { color: var(--color-text-muted); }
.sr-only { /* standard visually-hidden snippet */
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
}
Maybe twenty utilities total. Not two thousand.
Prevention tips: how to keep CSS sane long-term
A few habits I've picked up that make this approach actually stick:
-
Treat your tokens file as a contract. Adding a new color or spacing value should require a small amount of friction. If anyone can drop in
#2c5aa8whenever they feel like it, you're back where you started. - Audit your stylesheet once a quarter. Search for hardcoded hex values or magic numbers. Each one is a small bug waiting to happen.
- Don't nest deeper than two levels. Modern CSS supports nesting natively now, which is great, but the same rules apply that did with Sass — deep nesting wrecks specificity and makes selectors brittle.
-
Co-locate component CSS with components. If you're using a component framework, putting
Button.cssnext toButton.jsxmakes the relationship obvious and deletions easier.
None of this is novel. It's mostly the CSS architecture we collectively forgot when utility-first took over. But with @layer, custom properties, container queries, and native nesting all shipping in stable browsers, writing structured CSS in 2026 is genuinely more pleasant than it's been in a long time.
Give it a weekend on a small project. You might be surprised how much you stop missing the utility soup.
Top comments (0)