What Broke in the Tailwind CSS v4 Migration — @custom-variant and Dark Mode Implementation Notes
Go deeper on this topic
TanStack Start × Cloudflare
Full-stack setup with TanStack Start and Cloudflare Pages, plus Tailwind v4 migration
Article 3 of 4 in this series.
Next reads
IndexNow x Cloudflare Pages — Instant URL submissions from sitemap.xml
A practical workflow: treat sitemap.xml as the source of truth, then submit all URLs to IndexNow right after a Cloudflare Pages deploy to shorten discovery time for new content.
Why I Didn't Choose Next.js — The Decision Criteria for Picking TanStack Start
A record of the technical decision to choose TanStack Start + Cloudflare Pages over Next.js for a portfolio hosting 203 interactive components.
TanStack Start x Cloudflare Pages — A Complete Record of Pitfalls and Solutions
Five landmines hit while deploying TanStack Start v1 to CF Pages — Vite plugin ordering, ServerFn bugs, FOUC prevention, and more — with workarounds shown in real code.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Introduction -- Motivation for Migrating from v3 to v4
sakimytocom is a portfolio site with 211 interactive components. It was built with Tailwind CSS v3, and I decided to migrate when v4 was released.
There were three motivations for the migration:
- CSS-first configuration: I wanted to drop
tailwind.config.jsand move to a configuration system that lives entirely within CSS files. When you have over 200 components, the management cost of having config and CSS separated becomes non-trivial - Native Vite plugin integration: v4 provides the
@tailwindcss/viteplugin, eliminating the need for PostCSS configuration. This simplifies the Vite build pipeline - Performance: v4's engine uses Rust-based
Lightning CSSinternally, promising build speed improvements
The bottom line: the migration itself was completed in one day. However, things definitely broke. This article documents the breaking changes I encountered and their solutions.
What Broke #1: darkMode Configuration Removed
The v3 World
In v3, the dark mode toggle method was specified in tailwind.config.js:
// tailwind.config.js (v3)
module.exports = {
darkMode: 'class',
theme: {
extend: {
// ...
},
},
}Specifying darkMode: 'class' made the dark: prefix active when the dark class was present on the <html> element. Simple and straightforward.
What Changed in v4
In v4, tailwind.config.js itself was eliminated. The darkMode option that lived in the JavaScript config file is gone, replaced by @custom-variant in CSS where you define it yourself.
When I first learned about this change, I honestly thought "what a hassle." All 211 components use the dark: prefix. Would I need to rewrite everything?
Implementation: @custom-variant
In the end, it was solved by adding just two lines to CSS:
/* src/styles/app.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));@custom-variant is a new directive introduced in v4 that registers any CSS selector as a Tailwind variant. With the definition above, the dark: variant matches "elements with the .dark class, or their descendants."
The important thing is that existing dark: prefixes worked as-is. Not a single line of the 211 component templates needed to change. Since v4's @custom-variant simply registers the same variant name dark as v3's darkMode: 'class', backward compatibility is naturally preserved.
Adding the light: Variant
There was also a benefit that v3 didn't have. Since @custom-variant can define arbitrary variants, I was able to add a light: variant:
@custom-variant light (&:where(.light, .light *));sakimytocom defaults to dark mode. In v3, styles for "not dark mode" had to be written as bare classes without dark:. In v4, the light: variant can be used explicitly, making intent clear in the code:
/* v3: Only dark styles get a variant prefix, light uses bare classes */
.border-color {
@apply border-gray-200 dark:border-gray-800;
}
/* v4: Both modes are explicit */
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
@apply border-gray-800 light:border-gray-200;
}
}For a dark-mode-default site, bare classes serve dark mode while the light: variant handles light mode, greatly improving readability.
What Broke #2: tailwind.config.js Elimination and CSS-First Configuration
Config File Migration
In v3, theme customization, plugins, content paths -- everything went in tailwind.config.js:
// tailwind.config.js (v3)
module.exports = {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
brand: '#6366f1',
},
fontFamily: {
mono: ['JetBrains Mono', 'monospace'],
},
},
},
plugins: [
require('@tailwindcss/typography'),
],
}In v4, all of these are replaced by CSS directives:
/* src/styles/app.css (v4) */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));
@theme {
--color-brand: #6366f1;
--font-mono: 'JetBrains Mono', monospace;
}Content path specification is also no longer needed. v4 operates as a Vite plugin and auto-detects template files from Vite's module graph.
Vite Plugin Integration
In v3, Tailwind operated as a PostCSS plugin, requiring postcss.config.js. In v4, simply adding @tailwindcss/vite to vite.config.ts is enough:
// vite.config.ts
import { cloudflare } from '@cloudflare/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
server: {
port: 3000,
},
plugins: [
tailwindcss(),
tsConfigPaths({ projects: ['./tsconfig.json'] }),
cloudflare({ viteEnvironment: { name: 'ssr' } }),
tanstackStart(),
react(),
],
})The critical thing here is plugin order. tailwindcss() must always come first. Also, the Cloudflare plugin must be placed before TanStack Start. Getting this order wrong causes CSS processing to fail and breaks the build. This ordering issue cost several hours on sakimytocom.
postcss.config.js could be deleted. Having one fewer config file is good for the soul too.
What Broke #3: Utility Class Name Changes
v4 changed some utility class names. Here are the ones that affected sakimytocom, listed by impact:
bg-opacity-50->bg-black/50: Unified to color modifiersring-opacity-50->ring-black/50: Sameflex-shrink-0->shrink-0: Unified to short formsflex-grow->grow: Sameoverflow-clip->overflow-clip: No change (but behavior verification needed)decoration-clone->box-decoration-clone: Prefix added
The bg-opacity-* changes had the widest impact. Many of the 211 components use semi-transparent backgrounds. v4 unified to the format bg-black/50 where opacity is specified with a slash after the color value.
I handled this with find-and-replace, but cases where bg-opacity values were dynamically set via CSS variables required manual verification.
{/* v3 */}
<div className="bg-white bg-opacity-10 dark:bg-black dark:bg-opacity-20">
{/* v4 */}
<div className="bg-white/10 dark:bg-black/20">The v4 notation is clearly more concise and its intent is easier to read. There's migration cost, but the result is higher code quality.
The FOUC Battle -- The SSR + Dark Mode Default Trap
sakimytocom is an SSR site using TanStack Start. Dark mode is the default. This combination causes FOUC (Flash of Unstyled Content).
The Problem Structure
- The server renders HTML (dark mode styles applied)
- The browser receives HTML and begins display
- If the user selected light mode, the page displays in dark mode until JavaScript loads and React hydrates
- After hydration completes, it switches to light mode -> screen flashes
This problem exists in both Tailwind v3 and v4, but I re-verified the fix during the v4 migration.
Solution: Inline Script
An inline script is placed in head() of __root.tsx to set the theme class before React hydration:
// src/routes/__root.tsx
export const Route = createRootRoute({
head: () => ({
scripts: [
{
children: \`
(function() {
var t = localStorage.getItem('theme');
if (t === 'light' || (!t && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('light');
}
})();
\`,
},
],
}),
})Key points of this script:
- Executes immediately: Inline scripts in
<head>block HTML parsing, so the class is set before<body>renders - References localStorage: Respects the user's previous choice
- System preference fallback: If no value in localStorage, checks the
prefers-color-schememedia query. Since dark mode is default, only users who explicitly prefer light mode get thelightclass - Dark mode needs no class: The state without any class on
htmlis dark mode. Light mode only activates when thelightclass is present
suppressHydrationWarning
This inline script modifies the <html> element's class outside React's control. Therefore, the server-rendered <html> and client-side <html> may have mismatched classes. React detects this mismatch and issues a hydration warning.
Adding suppressHydrationWarning to the <html> element suppresses this warning. For cases like theme toggling where SSR/CSR mismatch is intentional, this attribute's use is justified.
Base CSS Design
Along with the v4 migration, I also reorganized @layer base in app.css:
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
@apply border-gray-800 light:border-gray-200;
}
html {
@apply bg-gray-950 text-gray-100;
color-scheme: dark;
}
html.light {
@apply bg-white text-gray-900;
color-scheme: light;
}
body {
@apply min-h-screen antialiased;
}
}The color-scheme property is easy to overlook but important. Without it, default styles for scrollbars and form elements don't match the OS theme. White scrollbars in dark mode look bad.
Default border color is also set here. In v3, border-gray-200 was Tailwind's default, but for a dark-mode-default site, border-gray-800 is more natural.
Migration Results
Config File Reduction
tailwind.config.js: Deletedpostcss.config.js: Deletedapp.css: Configuration consolidated with@custom-variantand@theme
Having configuration "contained in CSS" means all style-related information is consolidated in a single file.
Flexible Theme Switching with @custom-variant
Being able to explicitly use both dark: and light: variants improved readability of theme-related styles. The existence of the light: variant is especially valuable for dark-mode-default sites.
Build Time
Anecdotally, the dev server's HMR became faster. Likely the effect of v4's Rust-based engine. In a project with 211 components, CSS rebuilds happen frequently, so this improvement directly impacts the development experience.
In numbers, HMR averaged 200-300ms in v3, and seems to have improved to around 100ms in v4. However, I didn't do rigorous measurements, so treat this as a rough reference.
Summary: Lessons from the Tailwind v4 Migration
Major CSS Framework Version Upgrades Are About "Config Migration"
I braced for changing 211 component templates, but what actually took the most time was config-related migration. Removing tailwind.config.js, migrating from PostCSS to Vite plugin, understanding @custom-variant. Component code itself required virtually no changes beyond class name find-and-replace.
Don't Over-Trust Migration Tools
Tailwind's official migration guide and codemod tools exist, but project-specific configurations (Vite plugin ordering, Cloudflare integration, etc.) aren't covered. Official tools get you 80% there, but the remaining 20% takes the most time.
Dark Mode Default Should Be Decided at Design Time
FOUC prevention, color-scheme configuration, default border color values -- dark mode default has wide-reaching implications if "changed later." It was good to revisit this design during the v4 migration, but it should really be decided at project inception.
v4 Is a Change in the "Right Direction"
Config contained in CSS, PostCSS no longer needed, variants freely definable. All of v4's changes move toward "what a CSS framework should be." Migration cost exists, but the codebase is decidedly simpler after migration. Even with a 211-component project, migration completes in a day. There's nothing to fear.