Deep Dive into Tailwind CSS v4 Dark Mode and Variant Mechanism
A Real Case Study: Why My GlassCard Component Stopped Working
After upgrading from Tailwind CSS v3 to v4, I ran into a frustrating issue: a component called GlassCard on my page refused to switch themes.
The page background correctly turned dark, but this frosted glass card remained stubbornly light-colored. On the dark background, the white semi-transparent card looked jarring and ruined the user experience.
This article documents my complete journey of debugging and solving this problem. Along the way, I not only fixed the issue but also developed a thorough understanding of Tailwind CSS v4's dark mode mechanisms, CSS variable override principles, and several confusing CSS concepts that often trip people up.
1. The Problem: GlassCard "Freezing" in Place
1.1 The Component Code
Here's what the malfunctioning GlassCard component looked like:
// GlassCard.tsx
export function GlassCard({ children, className = '' }) {
return (
<div
className={`bg-white/40 dark:bg-black/40 border-white/20 dark:border-white/10 backdrop-blur-md rounded-xl p-6 ${className}`}
>
{children}
</div>
)
}
The code looks perfectly normal—standard Tailwind dark: prefix syntax that should work automatically.
1.2 Theme Configuration
The project uses next-themes for theme management, which is the standard solution for Next.js dark mode:
// app/providers.tsx
'use client'
import { ThemeProvider } from 'next-themes'
export function Providers({ children }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
)
}
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
1.3 The Symptoms
When clicking the theme toggle button:
- ✅ The page background (controlled by
body) correctly turned dark - ❌ The
GlassCardbackground remainedbg-white/40—unchanged
This was baffling. next-themes had clearly added class="dark" to the <html> element, so why weren't the Tailwind dark: styles being applied?
2. Root Cause: v3 and v4 Have Completely Different Dark Mode Mechanisms
2.1 How It Worked in v3
In Tailwind CSS v3, using class-based dark mode required explicit configuration in tailwind.config.js:
// tailwind.config.js
module.exports = {
darkMode: 'class', // Key configuration
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
}
This configuration told Tailwind: "Whenever the HTML element has the .dark class, activate all dark: prefixed styles."
The generated CSS looked something like this:
/* v3 generated dark: styles */
.dark\:bg-black\/40 {
background-color: rgb(0 0 0 / 0.4);
}
The selector was .dark\:bg-black\/40, depending on the presence of the .dark class on the HTML element.
2.2 What Changed in v4
After upgrading to v4, many developers (including me) found that copying over v3 configurations didn't work.
This is because Tailwind CSS v4 defaults to the Media Query strategy, fundamentally changing how dark mode gets triggered.
CSS Generated by v4 by Default
/* v4 default generated dark: styles */
@media (prefers-color-scheme: dark) {
.dark\:bg-black\/40 {
background-color: rgb(0 0 0 / 0.4);
}
}
Notice the difference? It uses @media (prefers-color-scheme: dark) instead of a class selector.
This means:
- Tailwind only listens to the operating system
- Even if your HTML has the
.darkclass added bynext-themes - As long as your OS is in light mode, Tailwind ignores everything
Why Did the Page Background Work But Not the Component?
You might ask: "If dark: is broken, why does the page background switch correctly?"
This brings us to CSS variables. The page background's switching has zero dependency on Tailwind's dark: variant—it's entirely handled by CSS custom properties:
/* globals.css */
:root {
--background: 255 255 255; /* White */
--foreground: 0 0 0; /* Black */
}
.dark {
--background: 9 10 11; /* Black */
--foreground: 255 255 255; /* White */
}
body {
background-color: rgb(var(--background));
color: rgb(var(--foreground));
}
Here's how this mechanism works:
next-themesadds the.darkclass to the<html>tag- The CSS variable
--backgroundgets reassigned under the.darkrule block bodyusesrgb(var(--background))to automatically reference the new value
This is pure native CSS cascading scope override—it has nothing to do with Tailwind's dark: variant, so Tailwind version upgrades don't affect it.
3. The Solution: Making v4 Recognize the .dark Class Again
3.1 Core: Use @custom-variant
To make Tailwind v4 use class selectors instead of media queries, you need to explicitly define the dark mode variant in CSS.
Add this to your app/globals.css (or your main CSS file):
@import 'tailwindcss';
/* Define the dark mode variant - core fix */
@custom-variant dark (&:where(.dark, .dark *));
This single line is the solution! After adding it, Tailwind regenerates class-based CSS selectors.
3.2 Explaining @custom-variant Syntax
Let me break down this line of code:
@custom-variant dark (&:where(.dark, .dark *));
@custom-variantis Tailwind v4's new directive for defining custom variantsdarkis the variant's name—using it means thedark:prefix(&:where(.dark, .dark *))is the selector condition, which means:.dark— the current element has the.darkclass itself.dark *— or it's a descendant of an element with the.darkclass:where()is a CSS function that writes selectors without affecting specificity
In plain English: "Activate styles in this variant when the element or its ancestor has the .dark class."
3.3 Effect After the Fix
After adding @custom-variant, recompiling generates CSS like this:
/* dark: styles after the fix */
.dark\:bg-black\/40,
:where(.dark, .dark *) .dark\:bg-black\/40 {
background-color: rgb(0 0 0 / 4);
}
Now, regardless of the operating system theme, as long as the <html> tag has the .dark class, all dark: prefixed Tailwind utilities will work.
3.4 Complete globals.css Configuration
For absolute certainty, here's the complete CSS configuration example:
/* app/globals.css */
@import 'tailwindcss';
/* Define dark mode variant - core fix */
@custom-variant dark (&:where(.dark, .dark *));
/* CSS variable definitions (optional, for non-Tailwind utilities) */
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
/* Global styles */
:root {
--background: 255 255 255;
--foreground: 0 0 0;
}
.dark {
--background: 9 10 11;
--foreground: 255 255 255;
}
body {
background-color: rgb(var(--background));
color: rgb(var(--foreground));
}
4. Concept Breakdown: What's the Difference Between variant, @variant, and @custom-variant?
4.1 Three Meanings of "variant" in Frontend Development
The word "variant" appears frequently in frontend, but its meaning varies by context:
4.1.1 Tailwind Utility Variants (Usage Perspective)
This is what we're most familiar with—hover:bg-blue-500, dark:text-white:
<button class="hover:bg-blue-500 dark:bg-gray-800">Button</button>
Here, hover: and dark: are state variants, describing "under what circumstances" styles should apply.
4.1.2 Component Library Style Variants (Design Perspective)
This is common in UI component libraries, like Ant Design buttons:
<Button type="primary">Primary Button</Button>
<Button type="dashed">Dashed Button</Button>
<Button type="text">Text Button</Button>
Here, primary, dashed, and text are style variants, distinguishing different appearances of the same component.
4.1.3 CSS Directive Variants (Definition Perspective)
This is a new concept introduced in Tailwind v4:
/* @variant: inline variant usage */
.my-element {
background: white;
@variant dark {
background: black;
}
}
/* @custom-variant: defining a new variant */
@custom-variant theme-midnight (&:where([data-theme="midnight"] *));
Both are defining variant conditions, telling Tailwind: "When should this variant be activated?"
4.2 @variant vs @custom-variant
These are two different directives:
| Directive | Purpose | Example |
|---|---|---|
@variant | Use a variant directly in a CSS block | background: white; @variant dark { background: black; } |
@custom-variant | Define an entirely new variant | @custom-variant midnight (&:where([data-theme="midnight"] *)) |
Simple memory trick:
@variantis about "using" a variant@custom-variantis about "creating" a variant
5. Deep Dive into @layer Three-Tier Architecture
Tailwind CSS divides styles into three layers—a significant innovation that solves the long-standing problem of style specificity conflicts.
5.1 Three-Layer Structure Diagram
┌─────────────────────────────────────────────────────────────┐
│ Tailwind CSS Layer Structure │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Layer 3: Utilities │ │
│ │ │ │
│ │ .p-4 { padding: 1rem; } │ │
│ │ .flex { display: flex; } │ │
│ │ .text-center { text-align: center; } │ │
│ │ │ │
│ │ Highest priority - overrides components and base │ │
│ └─────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Layer 2: Components │ │
│ │ │ │
│ │ .btn { @apply ... } │ │
│ │ .card { @apply ... } │ │
│ │ .modal { @apply ... } │ │
│ │ │ │
│ │ Medium priority - reusable component styles │ │
│ └─────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Layer 1: Base │ │
│ │ │ │
│ │ h1 { font-size: 2em; } │ │
│ │ a { text-decoration: underline; } │ │
│ │ * { box-sizing: border-box; } │ │
│ │ │ │
│ │ Lowest priority - browser default resets │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
5.2 Layer Details
Base Layer: The Foundation
@layer base {
/* Reset browser defaults */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Set base typography */
h1 {
font-size: 2.5rem;
font-weight: 700;
}
h2 {
font-size: 2rem;
font-weight: 600;
}
/* Set link defaults */
a {
color: inherit;
text-decoration: none;
}
}
When to use: Resetting browser defaults and setting consistent typography across the site.
Components Layer: Reusable Components
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors;
}
.btn-primary {
@apply bg-blue-500 text-white hover:bg-blue-600;
}
.btn-secondary {
@apply bg-gray-200 text-gray-800 hover:bg-gray-300;
}
.card {
@apply bg-white rounded-xl shadow-lg p-6;
}
}
When to use: Packaging common Tailwind class combinations into semantic, reusable class names.
Utilities Layer: Helper Classes
@layer utilities {
.text-balance {
text-wrap: balance;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
When to use: Adding utilities that Tailwind doesn't provide by default.
5.3 Why This Priority Design?
Imagine if there were no layers—you might encounter problems like this:
/* Your card style */
.card {
padding: 16px;
}
/* Some third-party plugin */
.card {
padding: 24px; /* Overrides yours! */
}
With layers, the priority is fixed:
- Utilities > Components > Base
So even if a third-party library uses the Components layer, your Utilities layer styles take precedence.
5.4 @layer Notes in v4
In Tailwind CSS v4, the @layer directive syntax has slightly changed:
/* v4 recommended syntax */
@layer utilities {
.my-utility {
/* utility styles */
}
}
/* v4 new @utility directive */
@utility my-utility {
/* new utility syntax */
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
6. next-themes Configuration Deep Dive
6.1 The attribute Property: Choosing a Switching Method
The attribute prop in next-themes determines where theme information gets stored.
Option 1: class Attribute (Most Common)
<ThemeProvider attribute="class" defaultTheme="system">
{children}
</ThemeProvider>
Generated HTML:
<!-- Light mode -->
<html class="light">
<!-- Dark mode -->
<html class="dark"></html>
</html>
Advantages:
- Excellent compatibility—works with nearly all frameworks
- Easy to debug—just look at the class name to know the current theme
Disadvantages:
- Class names might conflict with some CSS frameworks
Option 2: data-theme Attribute (Popular with Component Libraries)
<ThemeProvider attribute="data-theme" defaultTheme="system">
{children}
</ThemeProvider>
Generated HTML:
<!-- Light mode -->
<html data-theme="light">
<!-- Dark mode -->
<html data-theme="dark"></html>
</html>
Advantages:
- Won't conflict with regular class names
- Some component libraries (like DaisyUI, TDesign) use this approach
Disadvantages:
- Tailwind doesn't support data attribute variants by default—requires extra configuration
Adapting Tailwind for data-theme Attribute
If you're using attribute="data-theme", you need to modify your Tailwind config:
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class', "[data-theme='dark']"],
// ...other config
}
Or in v4, define it like this:
@import 'tailwindcss';
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
6.2 defaultTheme and enableSystem
<ThemeProvider
attribute="class"
defaultTheme="system" /* Default to system theme */
enableSystem={true} /* Allow system theme */
disableTransitionOnChange /* Optional: disable animations on change */
>
{children}
</ThemeProvider>
defaultTheme="system": Follows OS theme by defaultdefaultTheme="light": Defaults to light themeenableSystem={true}: Users can choose to follow system or manually switch in settings
7. Practical Case: Complete GlassCard Fix
7.1 Problem Recap
Our GlassCard component used bg-white/40 dark:bg-black/40, but after upgrading to v4, it wouldn't switch in dark mode.
7.2 Fix Steps
Step 1: Confirm Tailwind Version
npm list tailwindcss
Ensure it's v4.x:
tailwindcss@4.x.x
Step 2: Install Required Dependencies
npm install tailwindcss @tailwindcss/postcss next-themes
Step 3: Configure PostCSS (if using Vite or plain CSS)
// postcss.config.js
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
Step 4: Configure globals.css
/* app/globals.css */
@import 'tailwindcss';
/* Core fix: Define dark mode variant */
@custom-variant dark (&:where(.dark, .dark *));
/* Optional: Other custom variants */
@custom-variant midnight (&:where([data-theme="midnight"] *));
/* CSS variable definitions */
@theme {
--color-primary: #3b82f6;
--color-secondary: #64748b;
}
:root {
--background: 255 255 255;
--foreground: 0 0 0;
}
.dark {
--background: 9 10 11;
--foreground: 255 255 255;
}
body {
background-color: rgb(var(--background));
color: rgb(var(--foreground));
}
Step 5: Verify the Effect
Restart the dev server:
npm run dev
Now clicking the toggle button should make GlassCard correctly switch with the theme.
7.3 Still Not Working? Troubleshooting Checklist
-
Check if .dark class exists on HTML Open browser DevTools and check if the
<html>tag hasclass="dark". -
Check if CSS is generating correctly In DevTools Elements panel, search for
.dark\:bgand see if corresponding style rules exist. -
Check style specificity Other styles might be overriding yours—try
!importanttemporarily:<div className="!bg-black/40"> -
Clear caches Build caches sometimes prevent new configs from taking effect:
rm -rf .next node_modules/.cache npm run dev
8. Common Issues and Troubleshooting
8.1 Why Are My dark: Styles Not Working?
Possible causes:
-
Dark variant not defined → Add
@custom-variant dark (&:where(.dark, .dark *)); -
Variant definition position is wrong → Ensure
@custom-variantcomes after@import "tailwindcss" -
Selector is written incorrectly → Check if the selector inside the parentheses is correct
8.2 Flash of Unstyled Content (FOUC) in Light Mode?
This happens because theme styles haven't been applied when the page loads.
Solutions:
- Add an inline script in
<head>:
// app/layout.tsx
import { Html, Head, Body, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html>
<Head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
`,
}}
/>
</Head>
<Body>
<Main />
<NextScript />
</Body>
</Html>
)
}
- Or use
suppressHydrationWarning:
<html lang="en" suppressHydrationWarning>
8.3 How to Stack Multiple Variants?
Tailwind supports stacking multiple variants:
<button class="hover:bg-blue-500 dark:hover:bg-blue-600 focus:ring-2 dark:focus:ring-blue-400">Button</button>
Order from outside to inside: variant > pseudo-class
8.4 How to Use Custom Variants?
After defining @custom-variant, use it like any built-in variant:
@custom-variant midnight (&:where([data-theme="midnight"] *));
<div class="midnight:bg-purple-900 midnight:text-white">Midnight Theme Card</div>
9. Best Practices Summary
9.1 Dark Mode Design Principles
-
Don't Just Invert Colors Dark mode isn't simply reversing colors. Dark backgrounds should use dark gray (not pure black), and text should use soft white (not #fff).
-
Maintain Appropriate Contrast WCAG standards require text-to-background contrast of at least 4.5:1.
-
Reduce Saturation In dark mode, high-saturation colors often feel harsh—reduce saturation appropriately.
-
Consider Shadows Shadows are less visible in dark mode—consider using borders or semi-transparent backgrounds instead.
9.2 Tailwind v4 Dark Mode Configuration Template
/* globals.css */
@import 'tailwindcss';
/* Essential dark mode variant */
@custom-variant dark (&:where(.dark, .dark *));
/* Optional: Other custom variants */
@custom-variant midnight (&:where([data-theme="midnight"] *));
@custom-variant contrast (&:where([data-contrast="high"] *));
/* CSS variable definitions */
:root {
--background: 255 255 255;
--foreground: 0 0 0;
--card: 255 255 255;
--card-foreground: 0 0 0;
--primary: 59 130 246;
--primary-foreground: 255 255 255;
--secondary: 241 245 249;
--secondary-foreground: 0 0 0;
}
.dark {
--background: 9 10 11;
--foreground: 255 255 255;
--card: 15 23 42;
--card-foreground: 255 255 255;
--primary: 96 165 250;
--primary-foreground: 0 0 0;
--secondary: 30 41 59;
--secondary-foreground: 255 255 255;
}
/* Using CSS variables */
body {
background-color: rgb(var(--background));
color: rgb(var(--foreground));
}
.card {
background-color: rgb(var(--card));
color: rgb(var(--card-foreground));
}
.btn-primary {
background-color: rgb(var(--primary));
color: rgb(var(--primary-foreground));
}
9.3 Component Design Recommendations
-
Prefer CSS Variables Over dark: Classes This way, one set of variables controls multiple style properties simultaneously.
-
Encapsulate Theme-Aware Components
function Card({ className, children }) { const cardClass = 'bg-white dark:bg-slate-800 rounded-xl p-6' return <div className={`${cardClass} ${className}`}>{children}</div> } -
Test All States Ensure components display correctly in light mode, dark mode, and system-following mode.
10. Summary
10.1 Key Takeaways
-
Tailwind CSS v4 Defaults to Media Queries To use class-based switching, you must add
@custom-variant dark (&:where(.dark, .dark *)); -
CSS Variables Are a Separate Path CSS variables don't depend on Tailwind's dark: variant, so version upgrades don't affect them.
-
Understand the Difference Between @variant and @custom-variant
@variantis about "using" a variant@custom-variantis about "creating" a variant
-
@layer Three-Tier Architecture
- Base (foundation): Reset browser defaults
- Components (component layer): Encapsulate reusable components
- Utilities (utility layer): Add custom utility classes
-
next-themes attribute Options
class: Most common, adds class to html tagdata-theme: Used by some component libraries, adds data attribute to html tag
10.2 Migration Checklist
If you're migrating from v3 to v4:
- Confirm
darkMode: "class"is removed fromtailwind.config.js(v4 doesn't need it) - Add
@custom-variant darkdefinition in CSS - Remove old
@tailwind base/components/utilitiesdirectives (replace with@import) - Check if custom variants need migration
- Test in light mode, dark mode, and system-following mode
10.3 Looking Forward
Tailwind CSS v4's architecture is actually closer to native CSS thinking. Variants are essentially "conditional style rules"—which aligns with CSS's @media and pseudo-classes.
Once you understand this underlying logic, you won't just solve dark mode problems—you'll be able to extrapolate and create more flexible theming systems.
Comments
No comments yet. Be the first to share your thoughts!