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 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: 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: 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. 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 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 Start with a single file that holds your design tokens. This is the foundation everything else builds on: / tokens.css / / Spacing scale — pick one system and stick to it / / Type scale / / Dark mode? Just override the tokens. / Notice the color names are semantic ( The / main.css / @import url('reset.css') layer(reset); Now you don't have to worry about a 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 / / Visuals — all driven by tokens / / Interaction / .button--primary { .button--primary:hover { .button:disabled { The markup goes back to being readable: 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. 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 { Maybe twenty utilities total. Not two thousand. A few habits I've picked up that make this approach actually stick: None of this is novel. It's mostly the CSS architecture we collectively forgot when utility-first took over. But with Give it a weekend on a small project. You might be surprised how much you stop missing the utility soup.The problem: utility soup and cognitive overload
bg-blue-600 and pray nothing else uses it.Why naming things matters more than you'd think
.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
@layer, it holds up really well.Step 1: Define your tokens as custom properties
: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;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f0f10;
--color-surface: #1a1a1c;
--color-text: #f5f5f5;
}
}--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
@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:
@layer reset, base, components, utilities;
@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);.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
@layer components {
.button {
/ Layout /
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid transparent;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
cursor: pointer;
transition: background-color 120ms ease;
}
background: var(--color-accent);
color: white;
}
background: var(--color-accent-hover);
}
opacity: 0.5;
cursor: not-allowed;
}
}Step 4: Keep a small utility layer for genuine one-offs
.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);
}
}Prevention tips: how to keep CSS sane long-term
#2c5aa8 whenever they feel like it, you're back where you started.Button.css next to Button.jsx makes the relationship obvious and deletions easier.@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.
