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.

After the October performance work on 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.
The live home page on mobile, 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.
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 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:
/* 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:
- 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 inDESIGN.md. Raw palette colors (--palette-blue-600,--chart-emerald) are reserved for semantic roles and editorial artwork. - Semantic roles. Shared controls only reference semantic names such as
--color-text-subtle,--color-field,--color-invalid-borderand--focus-ring. - 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:
@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:
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:
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:
@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:
<Button variant="secondary" size="md" className="inquiry-submit-layout">
Send inquiry
</Button>@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.
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.
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:
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, 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:
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:
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.
(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 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:
- Beasties’ inverse AST dropped non-critical rules inside conditional blocks. Its
walkStyleRulesWithReverseMirrorpass partitions parent nodes into either the critical tree or the remainder tree after walking their children. Any@layer,@mediaor@supportsblock 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. - 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
.sharedand deleted it from the stylesheet on disk. When Page B ran next,.sharedwas already gone from the stylesheet, so Page B got it in neither its inline critical CSS nor the external file.
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:
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.
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. 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:
- Sidebar icon centering. A sidebar rule combined an independent
translateproperty withtransform. In production builds, the CSS minifier droppedtranslatewhentransformwas declared in the same block, knocking the active indicator off center. Composing a singletransformvalue in every state fixed it. - A class collision between the newsletter page and footer. Two separate newsletter stylesheets defined a bare
.newsletter-archiveclass. 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-archivefixed it. - Nested route active states. TanStack Router sets
aria-current="page"on ancestor links too, while the original sidebar only highlighted exact route matches. A dedicateddata-activeattribute 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.
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.





