5 min read

I Replaced Tailwind with Native CSS. Here's What I Gained and What I Lost

I've been writing Tailwind since v1. Five years, roughly, long enough that flex items-center justify-between used to come out of my fingers before I'd finished thinking the sentence. So when I removed it from a production app last month, I expected to hate the result.

I didn't. That's the part I'm still getting used to.

The app is a small marketing site plus a dashboard, around thirty screens. Tailwind wasn't the problem there. Nothing was slow, nothing was broken, nobody was complaining about the stack. I pulled it out anyway, mostly because I was tired of the config drift and the PostCSS chain, and honestly? A little tired of lg:flex-row and dark:bg-slate-900 repeating a design system I'd already written once in CSS custom properties.

Here's the report from the other side.

The first hour is the hardest

The muscle memory is real. My first component after the migration started with mb-4 and p-6 written by habit, attached to class names that no longer meant anything. It took about an hour of frustration before I stopped reaching for the old reflexes.

I kept the mental structure Tailwind taught me, though. Layers, a small utility set, tokens. That part transfers fine. @layer gives you the same ordering control without the framework:

@layer reset, tokens, base, components, utilities;

@layer tokens {
  :root {
    --space-1: 4px;
    --space-2: 8px;
    --space-3: 12px;
    --space-4: 16px;
    --radius: 8px;
    --brand: oklch(55% 0.2 255);
  }
}

@layer components {
  .btn {
    padding: var(--space-2) var(--space-4);
    border-radius: var(--radius);
    background: var(--brand);
    color: white;

    &:hover { filter: brightness(1.1); }

    &[data-variant="ghost"] {
      background: transparent;
      color: var(--brand);
    }
  }
}

That nesting is native CSS now. No Sass, no build step, no @apply. The & behaves the way you already expect it to.

What actually carried the migration

Three features did most of the work.

Custom properties for everything. Dark mode used to be dark: prefixes scattered across every template file. Now it's one media query that swaps the tokens:

@media (prefers-color-scheme: dark) {
  :root {
    --bg: oklch(20% 0.01 250);
    --text: oklch(92% 0.01 250);
  }
}

And color-mix() replaced my entire hand-maintained tint and shade palette. color-mix(in oklch, var(--brand) 12%, white) is a hover state, not a new swatch in the config file.

:has() deleted a category of JS. The dashboard has form fields that need to turn red when invalid. That used to be aria-invalid plus a class toggle plus a watcher. Now it's one rule:

.field:has(input:user-invalid) {
  border-color: oklch(55% 0.2 25);
}

It re-evaluates live as the user types. I deleted the validation styling logic entirely.

Container queries ended a lie I'd been telling myself. The dashboard has widgets that live in both a narrow sidebar and a wide main column. Same component, two layouts. With Tailwind I handled that with responsive prefixes tuned to the page's breakpoints, which is a roundabout way of saying "I guessed." Container queries respond to the space the widget actually gets:

.widget { container-type: inline-size; }

@container (min-width: 500px) {
  .widget__body { grid-template-columns: 1fr 2fr; }
}

What I genuinely miss

The migration wasn't a clean win, and I'd be lying if I said it was.

The hover:/focus:/dark: modifier syntax is just more compact than writing the selectors by hand. .btn:hover and .btn:focus-visible next to each other is more lines. Not more complex. More bytes. Fine for thirty screens. I'd think hard before doing this on a three-hundred-screen codebase with a design system team.

You also have to know CSS. That's the real price of admission. Tailwind enforced consistency. My p-4 was your p-4 across the whole company. Plain CSS hands that discipline back to the humans. Custom property names become your API, and bad names surface at the worst possible moment.

And file size needs babysitting. Tailwind purges unused classes for you. Hand-written CSS means every rule is someone's responsibility. I shipped a stale stylesheet to production and only noticed because the dashboard felt sluggish. A stylelint pass and a size check in CI fixed it, but that's tooling I didn't need before.

Would I do it again

The migration is three weeks old now. The CSS file is smaller than the generated output was, the markup is readable again, and I deleted three config files. For a small app, it's a straight win.

For a big team? Probably not. Tailwind earns its keep when ten people need to share a design language without debating it in every PR. But for solo work and small teams, the platform caught up. That's not a hot take anymore, it's just where we are.

I saved the layer scaffold and the container-query widget pattern in Snippet Ark so the next project starts from the working version instead of a blank file. Starting with a clean layer structure is the difference between a weekend project and a two-week regret.