Migrating a portfolio to Tailwind CSS v4
What actually changes when you move from the JavaScript config to the CSS-first setup - and the one thing that made dark mode simpler.
Tailwind v4 moves configuration out of tailwind.config.ts and into your CSS. I
migrated this site as part of a wider rebuild, and the interesting part wasn't
the syntax - it was how the change nudged me toward a better dark mode.
The mechanical part
Three things change immediately. The PostCSS plugin moves to its own package:
// postcss.config.mjs
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};The three @tailwind directives collapse into one import:
@import 'tailwindcss';And tailwind.config.ts goes away entirely. Theme values now live in an
@theme block, where each custom property generates the matching utilities.
Where it gets interesting
Here's the part worth the migration. In v3, theming meant writing every colour
twice - once for light, once behind dark::
<div class="bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-50"></div>That scales badly. Every new component is another chance to forget a dark:
variant, and you can't see the palette in one place.
With @theme inline, the generated utility points at a CSS variable rather than
baking in a literal value:
@theme inline {
--color-bg: var(--bg);
--color-text: var(--text);
}Now bg-bg resolves through --bg at runtime. Swap the variable, and every
utility follows. Define the palette once per theme:
:root {
--bg: #0a0a0b;
--text: #f2f2f3;
}
:root[data-theme='light'] {
--bg: #fcfcfb;
--text: #131316;
}The markup drops back to class="bg-bg text-text" and stays theme-agnostic. I
went from scattering dark: across every component to roughly two dozen lines
of tokens.
The gotcha: three theme states, not two
The mistake I made first was assuming a boolean. There are actually three states: explicit dark, explicit light, and no choice yet - where the system preference decides. Handle only the first two and system-light users get a dark page.
The fix is to redefine the tokens a third time, guarded so an explicit choice still wins:
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) {
--bg: #fcfcfb;
--text: #131316;
}
}The same logic applies to custom variants. If you write a light: variant that
only matches [data-theme='light'], it silently misses everyone on system
light - which is exactly how you end up shipping a sun icon to someone already
in light mode.
Was it worth it
For a small site, yes - mostly because the token indirection is a genuinely better pattern, and v4 makes it the natural one. The migration itself took under an hour. The dark mode rewrite it prompted was the actual win.