---
title: "Why I replaced Tailwind with plain old CSS"
url: "https://sarthakmishra.com/blog/tailwind-to-native-css"
description: "I replaced Tailwind, CVA and tailwind-merge with native CSS layers, tokens and component stylesheets. Every page got smaller, while the build directory grew."
image: "https://sarthakmishra.com/og-images/blog/tailwind-to-native-css.webp"
---

[View HTML](https://sarthakmishra.com/blog/tailwind-to-native-css) · [Site index](https://sarthakmishra.com/llms.txt)

November 11, 2026

# Why I replaced Tailwind with plain old CSS

I replaced Tailwind, CVA and tailwind-merge with native CSS layers, tokens and component stylesheets. Every page got smaller, while the build directory grew.

[Let's work together](https://sarthakmishra.com/blog/tailwind-to-native-css#inquiry)

![Central star connects by white lines to document, color palette, browser window, and four-square grid.](https://sarthakmishra.com/assets/tailwind-to-native-css-hero-TThS_4C_.jpeg)

After the [October performance work on TanStack Start](https://sarthakmishra.com/blog/optimizing-tanstack-start), I checked what CSS each route on this site was still downloading.

The short version is that almost every page was paying for UI it never rendered.

All 65 blog and work pages downloaded the stylesheet for interactive article widgets, even though only five posts used them. All four service pages downloaded a 600-line pricing calculator stylesheet that only `/services/migrations` needed. And every single page paid upfront for closed dialogs, selects, popovers, sliders, tooltips and modal search in the root stylesheet.

The goal was simple. **Every page should only download the styles for what it actually renders.**

My first instinct was to fix the boundaries and keep Tailwind. I moved the optional rules into adjacent `.css` files with `@apply`, excluded their components from the root scan with `@source not`, and kept small font subsets external.

It worked. Linked CSS dropped by **6.4 to 11.0 KB gzip** across all 76 prerendered pages, and the root stylesheet fell from **20.2 KB to 13.5 KB gzip**.

But look at what I was maintaining to make that happen. My components already had their own stylesheets, cascade layers and CSS variables. On top of them, I was still running Tailwind’s Vite plugin, `@apply` wrappers, `@source not` exclusions to fight the global scanner, a script that scraped class names out of compiled MDX, CVA variant maps, and `tailwind-merge` for the controls I hadn’t cleaned up yet.

I was keeping a whole stack of utility tooling just to stop Tailwind from doing what it was built to do.

So I removed Tailwind, CVA and `tailwind-merge`, and moved the design system into native CSS. **Every one of the 76 prerendered pages got smaller**, by 7.1 to 21.8 KB gzip across HTML, linked CSS and initial JavaScript, even though the build directory ended up with more compressed CSS than before.

![Lighthouse mobile report for the sarthakmishra.com home page: Performance, Accessibility, Best Practices and SEO all score 100. First Contentful Paint is 1.0 seconds, Largest Contentful Paint 1.0 seconds, Total Blocking Time 0 milliseconds, Cumulative Layout Shift 0, and Speed Index 1.5 seconds.](https://sarthakmishra.com/assets/lighthouse-home-mobile-2026-11-11-DteZ0TzT.jpeg)

The live home page on mobile, Lighthouse 13.5.0, November 11, 2026.

![Lighthouse desktop report for the sarthakmishra.com home page: Performance, Accessibility, Best Practices and SEO all score 100. First Contentful Paint is 0.3 seconds, Largest Contentful Paint 0.3 seconds, Total Blocking Time 0 milliseconds, Cumulative Layout Shift 0, and Speed Index 0.8 seconds.](https://sarthakmishra.com/assets/lighthouse-home-desktop-2026-11-11-Cu9YVynH.jpeg)

The same build on desktop, Lighthouse 13.5.0, November 11, 2026.

## Fighting the global scanner

Tailwind works best when one scanner walks your project and emits one shared stylesheet. The moment you want per-route and per-component stylesheets, that scanner starts working against you.

I’d already seen part of the problem during the TanStack Start optimization pass. When I pulled `cn` (`clsx` plus `tailwind-merge`, about **10.6 KB gzip**) out of `Button`, I found that 40 of 51 button call sites relied on the browser resolving conflicting utility classes at runtime, including `hidden` overriding the base `inline-flex`. Giving `Button` non-overlapping variants got `cn` off most initial page loads, though inputs, selects and dialogs still pulled it in when they rendered.

Splitting deferred controls and MDX widgets into their own stylesheets made the friction much worse.

If a lazy dialog’s component file stayed in Tailwind’s default scan, its utility classes landed in the eager root stylesheet anyway. Keeping them out meant adding `@source not` exclusions to the global stylesheet, wrapping utilities in `@apply` inside separate CSS files, and checking the built bundles after every change to make sure a shared class hadn’t dragged optional rules back into the initial load.

Before ripping Tailwind out, I checked whether another CSS tool fit this delivery model better.

| Option                                                 | How it ships CSS                                | Why I passed                                                                                             |
| ------------------------------------------------------ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Tailwind feature entrypoints (`@source`, `@reference`) | Separate generated sheets per source set        | Still needs manual scan boundaries, MDX class extraction and repeated Tailwind `@property` declarations  |
| StyleX (`@stylexjs/unplugin`)                          | Appends collected rules to one shared CSS asset | Designed around one upfront stylesheet, which puts optional widget and dialog rules back onto every page |
| UnoCSS (`per-module` or `dist-chunk`)                  | Experimental module or chunk sheets             | Experimental modes with no TanStack Start SSR manifest or React 19 stylesheet-resource integration       |
| Panda CSS or vanilla-extract                           | Generated `styled-system` or `.css.ts` modules  | Adds another compiler and generated files for features plain CSS already supports                        |

None of them fit. And when I listed what I actually needed from a styling tool, the browser already had all of it.

[Base UI](https://base-ui.com/react/handbook/styling) already handled control behavior and exposed state through `data-*` and `aria-*` attributes (`data-highlighted`, `data-popup-open`, `aria-invalid="true"`). Native CSS already had `@layer`, custom properties, `light-dark()`, `color-mix()`, native nesting and container queries.

I didn’t need another compiler. I just needed a clean set of rules for how native CSS files should compose.

## Ordering the cascade

When you use Tailwind, Preflight, theme variables and utilities have a built-in order. When you split native CSS across dozens of files, you have to choose that order yourself.

I wanted resets at the bottom, design tokens next, shared components above those, and feature styles on top.

The catch showed up during the first stylesheet split. **The dark Share button rendered with the wrong text color.**

React 19 hoists `<link rel="stylesheet" precedence="content">` tags into `<head>`. During server rendering, a component stylesheet can end up in `<head>` ahead of the root stylesheet. When that component stylesheet declared `@layer components` before the root stylesheet had a chance to declare the full layer order, the browser locked `components` in ahead of `reset` for the entire document. Our reset rules then beat the button’s text color.

The fix is a one-line layer prelude imported at the top of every standalone stylesheet:

```css
/* Resources discovered during SSR can precede the root stylesheet. */
@layer reset, tokens, base, components, features, utilities;
```

With that line in every deliverable file, the browser locks in the same cascade order no matter which `<link>` arrives first.

### Theme tokens in dark portals

Next came design tokens and theming. Instead of a Tailwind theme config and `.dark` selectors, I split tokens into three tiers of custom properties:

1. **Scales and palettes.** Spacing, font sizes, line heights and radii use their pixel size at a 16px root as the name, written in `rem` (`--space-24: 1.5rem`, `--text-14: 0.875rem`, `--radius-16: 1rem`). They scale with browser font preferences while matching the pixel numbers in `DESIGN.md`. Raw palette colors (`--palette-blue-600`, `--chart-emerald`) are reserved for semantic roles and editorial artwork.
2. **Semantic roles.** Shared controls only reference semantic names such as `--color-text-subtle`, `--color-field`, `--color-invalid-border` and `--focus-ring`.
3. **Component properties.** Local geometry and single-component derived colors (`--button-ring`, `--button-secondary-hover`) stay on the component’s root selector in its own stylesheet, instead of cluttering the global token file.

For light and dark mode, the root element sets `color-scheme` from `data-theme`, and semantic colors use native `light-dark()`.

My first pass put those color roles on `:root`. That worked everywhere except inside video settings and media popovers, which render inside forced-dark portals (`data-theme="dark"`) even when the page is in light mode.

In production builds, Lightning CSS lowers `light-dark()` for older browser targets into custom-property switches that resolve where the variable is declared. When the variables only lived on `:root`, a forced-dark portal inherited the already-resolved light values from `<html>`.

Declaring the semantic roles on both `:root` and `[data-theme]` forces any themed subtree to recompute its own colors:

```css
@layer tokens {
  :root,
  [data-theme='light'] {
    color-scheme: light;
  }

  [data-theme='dark'] {
    color-scheme: dark;
  }

  :root,
  [data-theme] {
    --color-foreground: light-dark(#161618, #fbfbfb);
    --color-background: light-dark(#fbfbfb, #161618);
    --color-field: color-mix(in oklab, var(--color-muted) 50%, transparent);
  }
}
```

## Separating appearance from placement

Why did `Button` need `tailwind-merge` in the first place? Because callers were passing utility classes into `className` to tweak spacing, colors or display modes, and the component had to resolve the collisions at runtime.

I wanted a stricter boundary. **A shared component owns how it looks. A parent only decides where it sits.**

Once you enforce that rule, you don’t need conflict resolution in JavaScript. `Button` maps `variant` (appearance) and `size` (geometry) directly to class names:

```tsx
const buttonOptions = {
  variant: {
    primary: 'ui-button-primary',
    secondary: 'ui-button-secondary',
    solid: 'ui-button-solid',
    soft: 'ui-button-soft',
    outline: 'ui-button-outline',
    'outline-muted': 'ui-button-outline-muted',
    ghost: 'ui-button-ghost',
    quiet: 'ui-button-quiet',
  },
  size: {
    sm: 'ui-button-size-sm',
    md: 'ui-button-size-md',
    lg: 'ui-button-size-lg',
    'icon-sm': 'ui-button-size-icon-sm',
    'icon-lg': 'ui-button-size-icon-lg',
    round: 'ui-button-size-round',
    inline: 'ui-button-size-inline',
    nav: 'ui-button-size-nav',
    pill: 'ui-button-size-pill',
    square: 'ui-button-size-square',
    row: 'ui-button-size-row',
  },
} as const;

function buttonVariants({
  variant = 'primary',
  size = 'sm',
  className,
}: ButtonVariants & { className?: string | null | undefined } = {}): string {
  return cx(
    'ui-button',
    variant && buttonOptions.variant[variant],
    size && buttonOptions.size[size],
    className,
  );
}
```

`cx` is an 85-byte gzip helper that drops falsy values and joins strings with a space. It does no merging because the classes are designed not to collide:

```ts
export function cx(...classes: (string | false | null | undefined)[]): string {
  return classes.filter(Boolean).join(' ');
}
```

Inside the stylesheet, resting defaults sit inside `:where(.ui-button)` at zero specificity. Every variant and size class outranks those defaults regardless of source order, while `:disabled`, `:focus-visible` and `[aria-invalid='true']` keep single-class specificity so state styles always win:

```css
@import '../../style/layers.css';

@layer components {
  :where(.ui-button) {
    --button-shadow: 0 0 transparent;
    --button-ring: 0 0 transparent;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: 1px solid transparent;
    font-size: var(--text-14);
    line-height: var(--text-14-leading);
    font-weight: 500;
    box-shadow: var(--button-ring), var(--button-shadow);
    transition-property:
      scale, background-color, border-color, color, opacity, box-shadow;
    transition-duration: var(--duration-fast);
    transition-timing-function: var(--ease-ui);
  }

  .ui-button:focus-visible {
    --button-ring: var(--focus-ring);
    border-color: var(--color-ring);
  }

  .ui-button-secondary {
    border-color: var(--color-input);
    background-color: var(--color-field);
    color: var(--color-text-secondary);
    cursor: pointer;
  }
}
```

When a parent form needs to position a button in its grid, it passes a class ending in `-layout`:

```tsx
<Button variant="secondary" size="md" className="inquiry-submit-layout">
  Send inquiry
</Button>
```

```css
@layer features {
  .inquiry-submit-layout {
    justify-self: end;
  }
}
```

Our lint checks allow `-layout` classes to set placement properties (`margin`, `grid-column`, `justify-self`, `width`, `flex`, `inset`) and reject padding, typography, color, border radius or descendant selectors.

## When one shared component becomes a junk drawer

After the first full conversion to native CSS, I measured the build.

Emitted JavaScript across the build fell by **29.3 KB gzip**. That was the good news. The bad news was that total emitted CSS grew by **4.9 KB gzip** (from 20 stylesheets to 39), and linked CSS grew slightly on 65 of 81 responses.

That wasn’t the result I wanted. So I opened the emitted stylesheets for each route to see what was sneaking in.

| Route                 | Linked sheets | Linked CSS, gzip | Unrelated selectors found in the route’s CSS                      |
| --------------------- | ------------- | ---------------- | ----------------------------------------------------------------- |
| Home                  | 4             | 12,445 B         | Video-player play button and migration-calculator attached button |
| About                 | 9             | 17,988 B         | Blog search field and `BeforeAfter` comparison slider             |
| Blog page 2           | 11            | 21,049 B         | `BeforeAfter` comparison slider                                   |
| GitHub for Government | 12            | 23,519 B         | Exam post’s learning ledger and portal post’s load-spike chart    |
| Migrations Service    | 15            | 27,322 B         | Blog search field and `BeforeAfter` comparison slider             |

Look at that first row. **The home page was downloading styles for the video player and the migration calculator.**

Why? Because over time, `Button` had accumulated **17 variants and 22 sizes**. Those included `.ui-button-player-play` for the video player and `.ui-button-attached` (plus `16rem` and `36rem` container-query breakpoints) for the migration calculator. Because Home imported `Button`, Home got every button variant on the site. Opening Contact’s `Select` also pulled in the video player’s button styles because `Select` borrowed a media variant from the same file.

Content stylesheets had the same grouping problem. The general content layout sheet bundled blog search and filter rules onto About. The media sheet bundled `BeforeAfter` slider rules onto every page with an image. And a single **2.5 KB gzip** widget stylesheet combined every interactive article widget, so a post with a simple bar chart also downloaded styles for an exam simulation ledger.

The fix was to split components by where they are actually used.

I cut `Button` back to the **8 variants and 11 sizes** used by ordinary actions and navigation links, sharing an internal `ButtonBase`. Specialized buttons moved into their own leaf components and stylesheets (`MediaButton`, `MediaArrowButton`, `CodeCopyButton`, `OverlayCloseButton`, `InputAction`, `StepperButton` and `ImageButton`). `Select` and `MediaButton` now share only a small media-setting stylesheet.

I also tried going one step further and splitting the common `Button` base and navigation link styles into separate tiny CSS files. That backfired. Home and listing pages always use both, so splitting them increased HTTP requests and compressed bytes. I put those co-used recipes back into `button.css`. **How you organize styles in source and how they travel over the network are two different problems.**

Splitting the specialized buttons, blog search, `BeforeAfter`, embeds, FAQs and the four widget families (`chart-widgets`, `comparison-widgets`, `learning-widgets` and `load-chart`) cut linked CSS across all 76 prerendered pages by another **94 to 6,101 bytes gzip**. About dropped from 9 linked stylesheets (**18.0 KB gzip**) to 3 (**11.9 KB gzip**), and the GitHub for Government post saved **4.6 KB gzip**.

## Loading styles for MDX posts

Splitting widget and media styles created a new problem. How does a server-rendered blog post know which stylesheets to link?

TanStack Start collects route CSS from static imports in its route manifest. MDX posts load through dynamic imports, so importing a component inside an MDX post doesn’t register its stylesheet in the route manifest for server rendering.

How stylesheets reach a page · Route and layout CSS imports · Start route manifest · MDX component imports and code fences · attachMdxStyles adds ?url links and contentStylesheets · Beasties inlines critical CSS and defers full sheets on prerenders · Route loader awaits loadStylesheets on client navigation · Dialogs, Search and video controls · Loaded on intent or near viewport

Route styles come from static imports. MDX compiler attachments and the route loader cover components inside posts, while overlays wait for interaction.

To bridge that gap, our MDX compiler inspects each post’s AST during compilation. When a post imports an image, carousel, diagram, video, calculator or widget component, or uses a fenced code block, the compiler prepends Vite `?url` imports and React 19 `<link rel="stylesheet" href={...} precedence="content">` tags, and exports a `contentStylesheets` array on the compiled MDX module:

```tsx
import __portfolioStyles0 from '@/features/content/post-image.css?url';
import __portfolioStyles1 from '@/features/content/diagram.css?url';
import { codeCopyButtonStylesheet as __portfolioStyles2 } from '@/components/ui/code-copy-button';

export const contentStylesheets = [
  __portfolioStyles0,
  __portfolioStyles1,
  __portfolioStyles2,
];
```

Leaf controls such as `CodeCopyButton`, `MediaArrowButton` and `Popover` render their own `<link rel="stylesheet" precedence="content">` tag and export their stylesheet URL so `contentStylesheets` can include it without emitting a duplicate `<link>`. If an MDX file imports a local component with a static CSS dependency that isn’t registered in the compiler map, the build fails immediately instead of shipping unstyled HTML.

While testing this in Chromium, Firefox and WebKit, I spotted two components downloading their stylesheets twice.

They had kept their old side-effect `import './...css'` lines alongside their new React `<link precedence="content">` tags. Vite’s import helper inserted a `<link>` with an absolute `href` and `crossorigin="anonymous"`, while React inserted a root-relative `href`. Because React deduplicates stylesheet resources by comparing the literal `href` string, it treated them as two different files. Removing the side-effect imports fixed the duplicate request.

### Fixing the single-frame footer flash

Moving post styles out of the global stylesheet exposed a much uglier bug in production preview. Clicking from Home or Blog into a post could flash a single frame showing **only the footer** before the article appeared.

Here is why that happened.

The route loader called `body.preload()`, which fetched the compiled MDX JavaScript module, not its `<link rel="stylesheet" precedence="content">` files. When the navigation committed and rendered the MDX body, React 19 suspended the article until those stylesheets finished downloading. Even though TanStack Router wraps route commits in `React.startTransition`, its `Match` component subscribes to the router store with `useSyncExternalStoreWithSelector`. [React cannot keep the old screen visible as a non-blocking transition when a component suspends from an external-store update](https://react.dev/reference/react/useSyncExternalStore#caveats), so it fell back to the nearest Suspense boundary and flashed the footer.

To prevent the flash, the route loader needed to wait for the destination post’s stylesheets before committing the navigation.

I tried `ReactDOM.preinit(href, { as: 'style', precedence: 'content' })` first. It had two problems. First, it returns `void`, so the route loader can’t await it. Second, when I aborted a stylesheet request in Chromium to test error recovery, React’s internal `link._p` promise rejected as an unhandled rejection and stayed cached in its failed state. Calling `preinit` again returned early without retrying the download.

So I wrote a small `loadStylesheets` helper that the route loader awaits before committing the route:

```ts
const found = [
  ...document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
].find((element) => element.href === url.href);
if (found && !found.dataset.precedence) {
  found.dataset.precedence = 'content';
  found.setAttribute('href', href);
}
if (found && applied(found)) return Promise.resolve();
const link = found ?? document.createElement('link');
```

If Vite has already inserted an absolute-URL `<link>` for that file, the helper rewrites its `href` to the root-relative path and sets `data-precedence="content"` so React adopts the existing DOM node. For a new stylesheet, it inserts `<link rel="stylesheet" data-precedence="content">`, waits for `load`, and verifies that the stylesheet’s rules are actually readable:

```ts
function applied(link: HTMLLinkElement): boolean {
  try {
    // A failed CSS request can still have a sheet in Chromium. Readable rules,
    // including an empty rule list, distinguish a parsed same-origin sheet.
    if (!link.sheet?.cssRules) return false;
  } catch {
    return false;
  }
  if (
    link.media === 'print' &&
    link.getAttribute('onload') === "this.media='all'"
  )
    link.media = 'all';
  return !link.disabled && window.matchMedia(link.media || 'all').matches;
}
```

Checking `Boolean(link.sheet)` isn’t enough. In Chromium, an aborted same-origin stylesheet request still attaches a non-null `sheet` object whose `cssRules` getter throws `SecurityError`. And if the `<link>` already exists in the initial prerendered document as a deferred `media="print"` stylesheet, `applied()` flips its media to `'all'` before resolving. If a newly created link fails or times out after 15 seconds, the helper removes it and evicts its cache entry so the next navigation can retry.

To test the fix, I throttled destination stylesheets by 1.5 seconds and navigated across Home, Blog and Work. **Zero footer-only frames.** The previous page stays on screen until the new post’s CSS is ready, and React adopts the loaded `<link>` tags without a second request.

## Two optimizations that made pages worse

By this point, splitting styles by component and MDX feature had grown the build output from **20 to 50 stylesheets**. Uncompressed CSS across the build directory shrank by **14.4 KB**, but compressing 50 files separately (each with its own `@layer` header) made the total gzipped CSS in the build folder **9.6 KB larger**.

That bothered me. Could I merge some of those files or strip duplicate rules without hurting real pages? I spun up isolated worktrees to run two experiments.

### Bundling stylesheets together

First, I tested five Rolldown chunk-grouping and Vite CSS-splitting configurations across all 76 prerendered pages.

| Configuration                | Emitted CSS files / total gzip | Home linked CSS (sheets) | About linked CSS (sheets) | Migrations linked CSS (sheets) |
| ---------------------------- | ------------------------------ | ------------------------ | ------------------------- | ------------------------------ |
| Automatic splitting (kept)   | 47 / 51.7 KB                   | 12.0 KB (4)              | 11.9 KB (3)               | 22.6 KB (11)                   |
| Single global CSS group      | 21 / 44.1 KB                   | 24.3 KB (1)              | 24.3 KB (1)               | 29.4 KB (5)                    |
| `cssCodeSplit: false`        | 18 / 39.0 KB                   | 24.4 KB (1)              | 24.4 KB (1)               | 29.5 KB (5)                    |
| Content foundation group     | 45 / 51.1 KB                   | 12.0 KB (4)              | 12.8 KB (3)               | 22.8 KB (10)                   |
| UI-only `entriesAware` group | 51 / 52.7 KB                   | 12.5 KB (5)              | 12.5 KB (5)               | 23.1 KB (12)                   |

*(This experiment ran at the 47-stylesheet stage, right before `Popover` and media overlays gained their own resources.)*

Merging CSS into a single global group or turning off `cssCodeSplit` did shrink the build folder, at the cost of **doubling Home’s linked CSS from 12.0 KB to 24.3 KB gzip** and shipping dialog, search, TOC and video-player styles to every page on the site.

A broad `entriesAware: true` group didn’t even build. The client build renamed explicit `?url` assets to `site-styles~...-BetrahDn.css`, while the SSR build still emitted `<link>` tags pointing at `/assets/post-image-BetrahDn.css`. Restricting `entriesAware: true` to UI components built cleanly, but added **1.0 KB of CSS**, **1.1 KB of JavaScript** and an extra stylesheet request on every sampled page.

What about merging each page’s CSS into a single route bundle after prerendering? I tried that too. In the static HTML, Home’s linked CSS dropped from **12.0 KB to 10.9 KB gzip**. Looked promising. Then client hydration ran, and Vite and React requested the original modular stylesheets from the client dependency graph anyway. Home ended up downloading **24.2 KB of CSS** instead of **13.3 KB**.

### Pruning inlined critical rules

On prerendered pages, [Beasties](https://github.com/danielroe/beasties) extracts the CSS rules used by the initial HTML, inlines them in `<head>`, and turns the full external stylesheets into non-blocking `media="print"` links that swap to `all` on load.

Beasties has a `pruneSource: true` option that deletes the inlined rules from the external stylesheets so the browser doesn’t download them a second time. In theory, that sounds like free savings.

In practice, enabling `pruneSource: true` broke the site in two ways:

1. **Beasties’ inverse AST dropped non-critical rules inside conditional blocks.** Its `walkStyleRulesWithReverseMirror` pass partitions parent nodes into either the critical tree or the remainder tree after walking their children. Any `@layer`, `@media` or `@supports` block that contained even one critical rule was discarded from the external remainder, taking every non-critical rule inside that block with it. On independent copies of the pages, every non-root stylesheet was wiped to **zero bytes**.
2. **Shared stylesheets mutated across pages depending on build order.** Beasties rewrote the external CSS files on disk as it processed each HTML page. Page A inlined `.shared` and deleted it from the stylesheet on disk. When Page B ran next, `.shared` was already gone from the stylesheet, so Page B got it in neither its inline critical CSS nor the external file.

| Sampled page            | Pristine inline critical CSS, raw | Forward-order pass, raw | Reverse-order pass, raw |
| ----------------------- | --------------------------------- | ----------------------- | ----------------------- |
| Home                    | 30,145 B                          | 30,145 B                | 8,319 B                 |
| About                   | 32,213 B                          | 12,259 B                | 3,202 B                 |
| Blog listing            | 29,522 B                          | 6,554 B                 | 6,160 B                 |
| Animated nav icons post | 35,749 B                          | 4,602 B                 | 4,093 B                 |
| Exam widgets post       | 37,680 B                          | 6,068 B                 | 7,409 B                 |
| Services                | 24,043 B                          | 0 B                     | 0 B                     |
| Migrations              | 39,071 B                          | 8,080 B                 | 39,071 B                |

Even if the AST bug were fixed, mutating `/assets/index-*.css` per page would put different bytes behind a single content-hashed filename. I kept `pruneSource: false`. Full external stylesheets stay immutable under a one-year `Cache-Control` header, and repeat navigations in the browser make **zero CSS requests**.

## Every page got smaller

I preserved the last Tailwind build (`f43fe28`) and compared it against the finished native-CSS build across all **76 prerendered pages** and five SSR responses, compressing every response and asset with gzip.

First, here is the whole build directory:

| Emitted assets           | Tailwind, KB gzip | Native CSS, KB gzip | Change         |
| ------------------------ | ----------------- | ------------------- | -------------- |
| Root stylesheet          | 13.53             | 9.49                | −4.05 (−29.9%) |
| Root JavaScript          | 24.97             | 22.89               | −2.08 (−8.3%)  |
| Shared vendor JavaScript | 96.85             | 96.87               | +0.03          |
| All CSS (20 → 50 files)  | 50.78             | 60.36               | +9.58 (+18.9%) |
| All JavaScript           | 1,252.57          | 1,227.57            | −25.01 (−2.0%) |
| All CSS and JavaScript   | 1,303.35          | 1,287.92            | −15.43 (−1.2%) |

Uncompressed CSS across the directory fell from **263.4 KB to 249.0 KB**, while splitting it across 50 files added **9.6 KB gzip** to the directory total.

Nobody downloads the build directory, though. Visitors load one page at a time. And on actual pages, the numbers went the other way. The root stylesheet shrank by **30%**, and optional styles stopped riding along on unrelated routes.

| Page                     | Linked CSS, before → after | Initial JS, before → after | HTML, before → after | Combined saved |
| ------------------------ | -------------------------- | -------------------------- | -------------------- | -------------- |
| Home                     | 14.17 → 12.22 KB           | 161.77 → 159.55 KB         | 38.84 → 34.76 KB     | 8.26 KB        |
| About                    | 19.76 → 11.72 KB           | 158.73 → 157.11 KB         | 20.60 → 16.91 KB     | 13.35 KB       |
| Blog listing             | 13.53 → 12.98 KB           | 179.94 → 166.58 KB         | 26.19 → 21.91 KB     | 18.20 KB       |
| Stop Fixing Exams        | 24.75 → 18.75 KB           | 157.19 → 155.21 KB         | 44.71 → 38.97 KB     | 13.72 KB       |
| Adaptive Video Streaming | 21.43 → 16.96 KB           | 157.19 → 155.21 KB         | 44.20 → 39.57 KB     | 11.09 KB       |
| Phidata                  | 20.75 → 16.46 KB           | 157.19 → 155.21 KB         | 34.82 → 30.05 KB     | 11.04 KB       |
| Services                 | 13.53 → 10.26 KB           | 156.67 → 154.69 KB         | 17.28 → 15.46 KB     | 7.07 KB        |
| Migrations               | 25.96 → 24.76 KB           | 188.64 → 175.53 KB         | 64.72 → 59.99 KB     | 19.04 KB       |
| Contact (SSR)            | 14.41 → 12.05 KB           | 182.01 → 168.61 KB         | —                    | 17.02 KB       |
| Newsletter Issue 1       | 19.87 → 14.42 KB           | 190.40 → 177.57 KB         | 21.53 → 18.06 KB     | 21.75 KB       |

Across all 76 prerendered pages, **HTML, linked CSS and initial JavaScript all dropped on every single page**:

- Linked CSS fell by **0.55 to 8.60 KB gzip** per page, and render-blocking CSS fell on 75 of 76 pages.
- Initial JavaScript fell by **1.61 to 13.36 KB gzip** per page.
- HTML fell by **0.92 to 6.14 KB gzip** per page because long utility class strings became short semantic class names and smaller inline critical CSS blocks. Inline critical CSS is already compressed inside the HTML, so it’s counted once there.
- Combined HTML, linked CSS and initial JavaScript fell by **7.07 to 21.75 KB gzip per page**, with a median saving of **12.81 KB (6.1%)**.

One detail worth calling out is the JavaScript drop. [Tailwind compiles to static CSS at build time and has no browser runtime](https://tailwindcss.com/docs/installation/using-vite). The JavaScript savings came from deleting `tailwind-merge` (`cn`) and `cva`, shorter class strings inside components, and narrower component imports. The old **10.6 KB gzip** `cn` chunk only entered the initial bundle on seven prerendered pages plus Contact SSR, and loaded on interaction elsewhere. Measuring real routes told a much more honest story than multiplying a dependency’s size by 76 pages.

## Replacing Tailwind’s guardrails

Touching 290 files across a design system is an easy way to break something subtle. Before deploying, I ran a **470-pair visual and DOM comparison** in Chromium against the preserved Tailwind build across all 76 prerendered pages, five SSR responses and 86 interactive states at `390×900` and `1440×900` in both light and dark themes.

That comparison caught three bugs before they shipped:

1. **Sidebar icon centering.** A sidebar rule combined an independent `translate` property with `transform`. In production builds, the CSS minifier dropped `translate` when `transform` was declared in the same block, knocking the active indicator off center. Composing a single `transform` value in every state fixed it.
2. **A class collision between the newsletter page and footer.** Two separate newsletter stylesheets defined a bare `.newsletter-archive` class. On the newsletter archive page, where both stylesheets loaded together, the footer rule altered the page’s spacing. Renaming the footer class to `.newsletter-subscription-archive` fixed it.
3. **Nested route active states.** TanStack Router sets `aria-current="page"` on ancestor links too, while the original sidebar only highlighted exact route matches. A dedicated `data-active` attribute kept exact-route emphasis on parent icons.

Notice that second bug. Two stylesheets defined the same class name and collided on one page.

That is the real tradeoff of leaving Tailwind. With utility classes in JSX, nobody creates a new CSS file that reaches into another component’s DOM or invents a `13px` padding token. With native CSS, nothing in the browser stops a feature stylesheet from overriding `.ui-button` or colliding with another file’s class name.

To keep the codebase from drifting, I replaced Tailwind’s accidental guardrails with explicit lint rules. **Stylelint** checks syntax and token vocabulary (banning `@apply`, `@theme`, `@utility`, raw hex/RGB/oklch colors, `transition: all`, and `.dark` or `[data-theme]` selectors outside the token file), and custom **PostCSS and Oxc AST checks** enforce component ownership at build time.

| Check                                                 | What it rejects                                                                                                          |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `design/shared-control`                               | Feature stylesheets targeting shared UI classes or `[data-slot]`                                                         |
| `design/foreign-class`                                | One stylesheet reaching into a class owned by another stylesheet                                                         |
| `design/base-recipe-owner`                            | Two stylesheets defining the same bare class (the `.newsletter-archive` bug)                                             |
| `design/layout-property` and `design/layout-selector` | Consumer `-layout` classes setting appearance properties or targeting descendants                                        |
| `design/semantic-color`                               | Shared controls using raw `--palette-*` or `--brand-*` tokens instead of semantic roles                                  |
| `design/shared-value`                                 | Spacing, font size, radius or easing declarations bypassing scale tokens without `calc()`, `min()`, `max()` or `clamp()` |
| `design/focus-token`                                  | `:focus` or `:focus-visible` rules that don’t use `--focus-ring`, `--focus-ring-inset` or `--focus-outline`              |
| `design/composed-transform`                           | Mixing `transform` with individual `translate`, `rotate` or `scale` properties in the same block                         |

Fifty stylesheets and custom AST checks are more machinery than dropping utility classes into JSX. For this site, I prefer it. Every component’s styles sit next to its code in ordinary CSS, `DESIGN.md` points to real tokens and classes instead of duplicating long utility strings, and every page downloads only the styles for what it renders. That’s a trade I’m happy to keep.

## Media sources

### How stylesheets reach a page

```mermaid
flowchart TD
R[Route and layout CSS imports] --> M[Start route manifest]
X[MDX component imports and code fences] --> A[attachMdxStyles adds ?url links and contentStylesheets]
M --> B[Beasties inlines critical CSS and defers full sheets on prerenders]
A --> B
A --> L[Route loader awaits loadStylesheets on client navigation]
I[Dialogs, Search and video controls] --> D[Loaded on intent or near viewport]
class M blue
class A blue
class B green
class L green
class D violet
```

## Structured data

```json
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "WebSite",
      "@id": "https://sarthakmishra.com/#website",
      "url": "https://sarthakmishra.com/",
      "name": "Sarthak Mishra",
      "publisher": {
        "@id": "https://sarthakmishra.com/#person"
      },
      "inLanguage": "en"
    },
    {
      "@type": "Person",
      "@id": "https://sarthakmishra.com/#person",
      "name": "Sarthak Mishra",
      "url": "https://sarthakmishra.com/about",
      "jobTitle": "Design Engineer",
      "image": "https://sarthakmishra.com/og-images/sarthak-mishra.webp",
      "sameAs": [
        "https://github.com/SarthakMishra",
        "https://www.linkedin.com/in/itssarthakmishra/",
        "https://x.com/MishraMishry"
      ]
    },
    {
      "@type": "WebPage",
      "@id": "https://sarthakmishra.com/blog/tailwind-to-native-css#webpage",
      "url": "https://sarthakmishra.com/blog/tailwind-to-native-css",
      "name": "Why I replaced Tailwind with plain old CSS - Sarthak",
      "description": "I replaced Tailwind, CVA and tailwind-merge with native CSS layers, tokens and component stylesheets. Every page got smaller, while the build directory grew.",
      "inLanguage": "en",
      "isPartOf": {
        "@id": "https://sarthakmishra.com/#website"
      },
      "mainEntity": {
        "@id": "https://sarthakmishra.com/blog/tailwind-to-native-css#content"
      },
      "breadcrumb": {
        "@id": "https://sarthakmishra.com/blog/tailwind-to-native-css#breadcrumb"
      }
    },
    {
      "@id": "https://sarthakmishra.com/blog/tailwind-to-native-css#content",
      "url": "https://sarthakmishra.com/blog/tailwind-to-native-css",
      "name": "Why I replaced Tailwind with plain old CSS",
      "description": "I replaced Tailwind, CVA and tailwind-merge with native CSS layers, tokens and component stylesheets. Every page got smaller, while the build directory grew.",
      "mainEntityOfPage": {
        "@id": "https://sarthakmishra.com/blog/tailwind-to-native-css#webpage"
      },
      "author": {
        "@id": "https://sarthakmishra.com/#person"
      },
      "datePublished": "2026-11-11T00:00:00.000Z",
      "inLanguage": "en",
      "keywords": [
        "css",
        "tailwind",
        "react",
        "design-systems",
        "performance"
      ],
      "@type": "BlogPosting",
      "headline": "Why I replaced Tailwind with plain old CSS",
      "publisher": {
        "@id": "https://sarthakmishra.com/#person"
      },
      "image": "https://sarthakmishra.com/og-images/blog/tailwind-to-native-css.webp"
    },
    {
      "@type": "BreadcrumbList",
      "@id": "https://sarthakmishra.com/blog/tailwind-to-native-css#breadcrumb",
      "itemListElement": [
        {
          "@type": "ListItem",
          "position": 1,
          "name": "Home",
          "item": "https://sarthakmishra.com/"
        },
        {
          "@type": "ListItem",
          "position": 2,
          "name": "Blog",
          "item": "https://sarthakmishra.com/blog"
        },
        {
          "@type": "ListItem",
          "position": 3,
          "name": "Why I replaced Tailwind with plain old CSS",
          "item": "https://sarthakmishra.com/blog/tailwind-to-native-css"
        }
      ]
    }
  ]
}
```
