Tailwind CSS v4 + CSS-First Styling: The Migration That Made the Design System Honest
Part 4 explains the real Tailwind CSS v4 styling work in this repo, including CSS-first configuration, custom utilities, design tokens, dark mode, border compatibility, and Radix/CSP edge cases.
This is Part 4 of the "Building a High-Performance Blog" series. Part 1 covered the framework decision, Part 2 covered rendering and caching, and Part 3 covered TypeScript and runtime validation. This one is about styling, which sounds softer until you are staring at a dark-mode regression at midnight and realizing CSS is absolutely part of your production system.
Real Situation
By the time the blog rendering path felt solid, I had a different kind of performance problem.
Not a server problem. Not a caching problem. A styling problem.
The site had grown past "just use some Tailwind classes." It had article typography, code blocks, dark mode, shadcn/Radix components, NoteSensei UI, legacy brand colors, newer editorial tokens, and a bunch of pages that were clearly written at different moments in the project. Everything worked, but the CSS had started to feel like sediment.
That part looks almost too clean. A four-line config can make you believe the migration was mostly a package update.
It was not.
The real work was in the CSS entrypoint and the discipline around what belongs in Tailwind config, what belongs in global CSS, and what should stay as component-level utility classes.
That is the first interesting tension in this repo. Tailwind v4 wants a CSS-first world. This project is halfway there, but it still carries a tailwind.config.ts because the system has real compatibility needs: content paths, legacy tokens, font families, shadcn color mapping, and the animation definitions that older components still expect.
I could pretend that is messy. Honestly, I think it is just honest.
What Went Wrong
The mistake I almost made was treating styling as a cleanup task.
That is usually how CSS work gets framed. "Polish." "UI cleanup." "Make it consistent." Those words are dangerous because they make styling sound optional. But in a content site, styling is part of the reading path. If article typography breaks, the product breaks. If dark mode flashes or loses contrast, the product breaks. If code blocks become hard to scan, the whole point of the blog gets weaker.
The migration guide in this repo was blunt about that:
markdown
**NON-NEGOTIABLE**: Light and dark mode themes MUST remain pixel-perfect.
Zero visual regressions allowed. UI/UX optimizations are post-migration only.
That sounds dramatic until you remember how many little things Tailwind v4 can disturb.
One concrete example: border defaults.
Tailwind v4 changed the default border color behavior, so border could stop looking like the old site even if the markup did not change. The fix lives right in app/globals.css:
css
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
This is the kind of migration code that never makes it into pretty blog posts.
But this is the code that keeps the UI from feeling subtly wrong.
Tension
I wanted the v4 setup to be pure. CSS-first, clean config, fewer knobs, no compatibility scaffolding.
The codebase wanted something else: do not break the product.
That is the tension. The framework nudges you toward a simpler model. The production app reminds you that users do not care how elegant your migration looks in a diff.
Surprise
The most annoying styling bug was not even a Tailwind utility problem.
It was Radix, CSP, and hidden inputs.
Radix renders hidden input elements for accessibility and form behavior. Some of those rely on inline styles. With a strict CSP, those inline styles can be blocked, and suddenly an element that was supposed to be invisible can leak into the UI. That is not the kind of bug you predict from reading a Tailwind changelog.
The fix is very specific:
css
/* Radix UI renders hidden <input> elements with inline styles for form/a11y.
CSP blocks those inline styles, making them visible. Hide via stylesheet. */input[aria-hidden='true'][tabindex='-1'] {
position: absolute !important;
width: 1px!important;
height: 1px!important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
clip-path: inset(50%) !important;
white-space: nowrap !important;
border: 0!important;
padding: 0!important;
margin: -1px!important;
appearance: none !important;
}
That fix changed how I thought about the migration.
It was not just "Tailwind v4 changed some utilities." It was "the styling layer is where framework defaults, component library behavior, browser security policy, and accessibility implementation details all meet."
That is systems work. It just happens to be written in CSS.
Insight
The useful insight was this: CSS-first does not mean "less CSS." It means the important CSS needs to live where CSS can actually own it.
The article typography is a good example.
I do not want the shape of a technical article scattered across twenty React components. The reading experience has a real system behind it: headings, counters, code blocks, tables, links, blockquotes, image treatment, and dark-mode contrast. That belongs in one coherent layer.
That is not a small utility. It is also not accidental CSS.
It is a real component contract for long-form writing. It says: articles have a rhythm, headings carry hierarchy, code blocks need contrast, and prose should not be left to whatever default a Markdown renderer happens to produce.
The same thing happened with tokens.
The v4 direction is CSS-first, and the repo reflects that with design tokens in :root and .dark:
This is where the emotional part of the migration showed up for me.
Before this, it was too easy to reach for another one-off color. One more arbitrary value. One more "just this page" tweak. Nothing felt dangerous in isolation. But after enough of those, the site starts losing its own voice.
Design tokens made the system harder to lie to.
If the page needs primary text, it uses --ink. If it needs the faint supporting tone, it uses --ink-faint. If a rule line looks wrong in dark mode, there is one place to reason about it.
That felt like getting control back.
Learning Moment
I expected Tailwind v4 to make the CSS smaller and faster. Maybe it does in places, but I am not going to invent a number for that.
The more important win was architectural: the styling system became easier to reason about.
That is less tweetable. It is also more valuable.
Principle
The principle I took from this work is simple:
Treat styling as a production boundary, not decoration.
For this repo, that means a few concrete rules.
First: use Tailwind utilities for local layout and spacing. That is where Tailwind is excellent.
Second: use CSS variables for semantic design decisions. Text color, surfaces, borders, dark mode, and brand tones should not be rediscovered in every component.
Third: use @utility when a pattern has become a real product primitive.
That utility exists because the old pattern was duplicated and fragile. The new version says what the UI wants instead of spelling out the gradient every time.
Fourth: avoid @apply.
This is one of those rules that sounds dogmatic until you have maintained a large Tailwind codebase. @apply starts as convenience and slowly turns into a second component system hiding inside CSS. Tailwind v4 gives better primitives: CSS variables, @theme, @utility, and regular utility classes. Use those.
The repo still has a hybrid config because migration is real life:
ts
constconfig: Config = {
darkMode: 'class',
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./contexts/**/*.{js,ts,jsx,tsx,mdx}',
'./hooks/**/*.{js,ts,jsx,tsx,mdx}',
'./lib/**/*.{js,ts,jsx,tsx,mdx}',
'*.{js,ts,jsx,tsx,mdx}',
],
// v4 auto-tree-shakes, no need for safelist or corePlugins optimizationtheme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
border: {
DEFAULT: 'hsl(var(--border))',
default: 'var(--border-default)',
strong: 'var(--border-strong)',
},
},
},
},
// v4: plugins are imported via CSS @plugin directive in globals.cssplugins: [],
};
That is not the final form I would design from scratch. But it is a sane bridge from an existing site to a cleaner v4 model.
And that is the real engineering move here: do not confuse a migration with a rewrite.
Mistake
The mistake would have been using Tailwind v4 as permission to redesign everything.
That is tempting. New version, new config model, new CSS architecture. Suddenly every old component looks suspect.
But that would have mixed two different jobs:
migrate the styling engine
redesign the product
Those need separate passes. If you do both at once, every visual regression becomes ambiguous. Was it the Tailwind migration? The token refactor? A component redesign? A dark-mode bug? A typography change?
I hate debugging that kind of soup.
The better move is boring: preserve the product first, then improve it deliberately.