Why I Replaced Tailwind CSS With Vanilla CSS

The Tailwind CSS logo, an arrow, and the official CSS logo, showing a move from Tailwind to vanilla CSS

I removed Tailwind CSS from this site and rewrote every style by hand in vanilla CSS. The reason is short: modern CSS now does natively what I was pulling Tailwind in to get. Cascade layers, custom properties, nesting, and :has() all ship in every current browser, and Tailwind v4 is built on those exact primitives. Once the browser speaks the language directly, a build step that generates it for you reads as overhead rather than leverage. When I rebuilt this site in December 2025 I reached for Tailwind v4 on instinct. This month I took it back out.

TL;DR

  • The features I used Tailwind for (design tokens, nesting, scoping, a consistent scale) are now native CSS or handled by Astro’s scoped styles.
  • Tailwind v4 is itself built on native cascade layers, custom properties, and color-mix(), with Lightning CSS doing the build. I was shipping an engine to generate CSS the browser already understands.
  • The migration dropped three Tailwind packages, moved global styles into native @layers, and put every component’s styles in Astro’s scoped <style> blocks.
  • The site stayed Baseline Widely available the whole way.

Why replace Tailwind at all?

Because the job Tailwind did for me is now built into the platform. When I list what I actually reached for it to solve, every item has a native answer today:

What I used Tailwind for The vanilla replacement Baseline Widely since
Style layering and predictable overrides @layer cascade layers Sept 2024
Design tokens and theming Custom properties (--x) long-standing
Nesting without a preprocessor Native CSS nesting (&) June 2026
Parent and state selectors :has() June 2026
Component-scoped responsive rules Container queries and Astro scoped styles Aug 2025

The dates are the point. CSS nesting and :has() only crossed into Baseline Widely availability around June 2026, roughly two months before I did this. Cascade layers got there in September 2024, color-mix() in November 2025, size container queries in August 2025. The Chrome team’s CSS Wrapped 2025 counted 22 new CSS features landing in Chrome in a single year. The gap Tailwind was filling closed while I wasn’t looking, and 2026 is the year it actually finished closing.

None of this makes Tailwind bad. It makes it optional for what I’m doing here: a single-author content site where nobody but me touches the CSS.

Tailwind v4 already runs on the CSS I switched to

The thing that tipped me over was reading how Tailwind v4 works under the hood. Its January 2025 rewrite is explicit that it is built on modern CSS. The announcement lists “native cascade layers,” “registered custom properties,” “color-mix(),” and logical properties as the foundation. The earlier post introducing its Oxide engine is blunter about the plumbing: the engine is “up to 10x faster,” and “the only thing the new engine depends on is Lightning CSS.”

Read that back with a migration in mind. Tailwind v4 generates native cascade layers, sets custom properties, and runs the output through Lightning CSS for prefixing and minification. That is the same stack I now write directly. I was running an engine whose job was to emit CSS features my target browsers already implement. For an app with dozens of engineers and a shared design system, that engine buys you consistency and guardrails worth paying for. For this site, it was a generator sitting between me and CSS I could just write.

There is a small irony worth stating plainly. Tailwind v4 leans on registered custom properties (@property), which won’t reach Baseline Widely availability until around January 2027, so Tailwind ships its own fallbacks for it. My hand-written CSS holds a stricter bar than the framework I removed.

What the migration actually looked like

The site is Astro, so it splits cleanly in two: global styles in src/styles, and per-component styles in each .astro file’s scoped <style> block. I rewrote both.

Cascade layers replace the Tailwind import

The old entry point started with Tailwind’s import, its typography plugin, and a @custom-variant line for every palette so theme selectors would compile:

@import "tailwindcss";
@plugin "@tailwindcss/typography";

@custom-variant tokyo-night (&:where([data-theme="tokyo-night"]));
@custom-variant dracula (&:where([data-theme="dracula"]));
/* ...thirteen more palettes... */
@custom-variant dark (&:where([data-mode="dark"], [data-mode="dark"] *));

Fifteen @custom-variant declarations, one per palette, plus one for dark mode, all to teach the framework about selectors the browser already understands. The replacement is one line of real CSS that declares the cascade order, then a plain file per layer:

@layer reset, tokens, base, primitives, prose, utilities;

Ordering the layers by hand turned out to be the feature I most underrated in Tailwind. Layers win by declaration order regardless of selector specificity, so the reset can be dead simple, tokens can sit under everything, and utilities win last without a single !important. I get the exact override behavior Tailwind gave me, spelled out in one readable statement.

Design tokens are just custom properties

Theming didn’t really change, which is the tell. Under Tailwind the palettes were already CSS variables swapped by a data-theme attribute; the framework just wrapped them in @theme and @custom-variant. Stripping that wrapper left plain rules:

:root,
[data-theme="catppuccin-mocha"] {
  --bg: #11111b;
  --ink: #cdd6f4;
  --accent: #89b4fa;
  /* ...the rest of the reduced token set... */
}

Sixteen of those blocks, one per theme, in a tokens layer. The switcher sets data-theme on <html>, the variables cascade in, and syntax highlighting tracks them through more variables. No config object, no plugin API, just the custom properties that were doing the work all along.

Components: from class lists to scoped styles

This is where the day-to-day difference shows. Here is the card component before, as a Tailwind class list:

<article
  class:list={[
    "relative rounded border bg-card p-4 sm:p-6",
    "has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-ring",
    className,
  ]}
  {...props}
>
  <slot />
</article>

And after, as scoped CSS in the same file:

<article class={className} {...props}>
  <slot />
</article>

<style>
  article {
    position: relative;
    border-radius: var(--radius);
    border-width: 1px;
    background-color: var(--bg-elevated);
    padding: 1rem;

    &:has(:focus-visible) {
      outline: 2px solid var(--accent);
      outline-offset: 2px;
    }
  }

  @media (width >= 40rem) {
    article {
      padding: 1.5rem;
    }
  }
</style>

The markup goes quiet and the intent moves into CSS that reads like CSS. Native nesting holds the :has() state rule next to the base rule, and a normal media query handles the responsive padding that sm:p-6 used to encode. Astro scopes these styles through a generated data-astro-cid attribute, so a bare article selector is enough and the element needs no class of its own. And because scoped styles compile unlayered, they beat the global layers without any specificity games. That last property is what made the migration safe to do component by component: a scoped rule always wins over a global primitive, so I could move one file at a time without the two systems fighting.

Lightning CSS does the vendor work

The reflex worry with dropping a framework is that you inherit its chores: prefixing, downleveling new syntax, minifying. I inherited none of them, because Lightning CSS was already in the pipeline. It’s a Rust parser, transformer, and minifier by Devon Govett, and it is the same tool Tailwind v4’s Oxide engine depends on. Astro’s Rust compiler uses it too, so turning it on for my own CSS was one line:

vite: {
  css: { transformer: "lightningcss" },
  build: {
    cssTarget: ["chrome111", "edge111", "firefox114", "safari16.4", "ios16.4"],
  },
}

It adds the handful of -webkit- prefixes my target browsers still need, lowers nesting for the stragglers, and minifies, all against a Baseline target set. The package.json diff was the satisfying part: out went tailwindcss, @tailwindcss/vite, and @tailwindcss/typography; in went a single lightningcss dev dependency that was effectively already there. The long-form article styling followed the same instinct I wrote about in shadcn/typeset vs Tailwind Typography: own the file, delete the rules you don’t want.

Did the site stay Baseline compliant?

Yes, and that was a hard constraint, not a nice-to-have. Every feature this site ships has to be Baseline Widely available, interoperable across the core browsers for at least 30 months. That’s exactly why the timing worked: the two features I lean on hardest, native nesting and :has(), only reached Widely availability around June 2026. If I’d tried this a year earlier they’d have been Baseline Newly at best, and the card component above would have needed an @supports gate with a full fallback. Doing it now, they’re safe to use unguarded. The one place I still gate behind @supports is the anchor-positioned reading indicator in the table of contents, which is genuinely still Newly available.

Do you still need Tailwind in 2026?

Only if someone other than you owns the CSS. That is the honest split, and it decides whether replacing Tailwind with vanilla CSS is worth it for you:

  • A team with a shared design system: probably keep Tailwind. The constraints that feel like overhead on a solo site are the whole point when twenty people style the same product. Consistency you don’t have to enforce by review is worth a build step.
  • A solo project or a content site: the trade-off has flipped. You get design tokens from custom properties, override control from cascade layers, nesting and state selectors natively, and scoping from your framework’s components. The framework’s value is real but no longer necessary.

I’m not claiming Tailwind lost. Kevin Powell has spent years arguing that learning CSS itself pays off, and the platform finally caught up to make that cheap. Tailwind v4 is a genuinely good piece of engineering, built on the same modern CSS that made me comfortable leaving it. For this site, hand-written CSS wired to theme variables is less code, one fewer dependency, and output I can read top to bottom. That’s the same own-the-source call I keep making here, and this time the browser did most of the work for me.