Skip to content
jarrettyew.com
← All writing

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.

Tech3 min readTailwindCSS

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.