tailwind-v4-dark-mode
Fix or implement dark mode in Tailwind CSS v4 projects. Use when dark mode colors are wrong, CSS variables aren't switching, utilities show hardcoded colors, or when setting up class-based dark mode with semantic design tokens.
Tailwind CSS v4 Dark Mode
Diagnose and fix dark mode issues in Tailwind CSS v4 (4.x) projects. This skill encodes hard-won knowledge about how TW4's build pipeline interacts with CSS custom properties and dark mode — pitfalls that are not obvious from the docs alone.
When to Use This Skill
- Dark mode colors are wrong (e.g., white cards on a dark background)
- CSS variables defined in
:root/.darkaren't switching in dark mode dark:variants useprefers-color-schemeinstead of class-based.dark- Setting up a semantic design token system (surface, text-primary, border, etc.) with dark mode
- Migrating a Tailwind v3 dark mode setup to v4
Critical Knowledge: How TW4 Processes Colors
The @theme Directive
In Tailwind CSS v4, @theme replaces tailwind.config.js for defining design tokens. Variables defined in @theme auto-generate utility classes:
@theme {
--color-surface: #ffffff;
}
/* Auto-generates: bg-surface, text-surface, border-surface, etc. */
Key behavior: @theme emits CSS variables on :root AND generates utilities that reference those variables via var(). This means runtime overrides of the variable (e.g., in .dark) WILL affect the utilities.
The Three @theme Modes
| Mode | Emits CSS var? | Utility uses var()? | Use for |
|---|---|---|---|
@theme { } | Yes | Yes | Tokens that change at runtime (dark mode) |
@theme inline { } | No | No (inlines value) | Values only needed at build time |
@theme reference { } | No | N/A (no utility) | Reference-only values |
Rule: Never use @theme inline for colors that need to change in dark mode. The values will be hardcoded into utilities and CSS variable overrides will be ignored.
The Correct Pattern for Dark Mode
Step 1: Configure Class-Based Dark Mode
TW4 defaults to prefers-color-scheme media queries for the dark: variant. To use class-based dark mode (.dark on <html>), add this AFTER the import:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Without this line, ALL dark: utilities in your components (like dark:bg-slate-900, dark:text-green-400) will use @media (prefers-color-scheme: dark) and won't respond to the .dark class toggle.
Step 2: Define Semantic Tokens in @theme
@theme {
/* Brand colors (static — same in light/dark) */
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
/* Semantic tokens (light defaults — overridden in dark) */
--color-surface: #ffffff;
--color-surface-secondary: #f9fafb;
--color-surface-hover: #f3f4f6;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-text-muted: #9ca3af;
--color-border: #e5e7eb;
--color-border-strong: #d1d5db;
}
This registers the tokens with Tailwind so it generates utilities like bg-surface, text-text-primary, border-border, etc. — all using var() references.
Step 3: Override Tokens in Dark Mode
Use @layer theme with @variant dark to override the CSS variables:
@layer theme {
:root {
color-scheme: light;
@variant dark {
color-scheme: dark;
--color-surface: #0f172a;
--color-surface-secondary: #1e293b;
--color-surface-hover: #334155;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-border: #334155;
--color-border-strong: #475569;
}
}
}
This compiles to html.dark { --color-surface: #0f172a; ... } which overrides the @theme-defined values at runtime.
Complete Example
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@theme {
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-surface: #ffffff;
--color-surface-secondary: #f9fafb;
--color-surface-hover: #f3f4f6;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-text-muted: #9ca3af;
--color-border: #e5e7eb;
--color-border-strong: #d1d5db;
}
@layer theme {
:root {
color-scheme: light;
@variant dark {
color-scheme: dark;
--color-surface: #0f172a;
--color-surface-secondary: #1e293b;
--color-surface-hover: #334155;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-border: #334155;
--color-border-strong: #475569;
}
}
}
body {
background: var(--color-surface);
color: var(--color-text-primary);
}
Then in components, just use the semantic utilities:
<div className="bg-surface border border-border">
<h1 className="text-text-primary">Title</h1>
<p className="text-text-secondary">Subtitle</p>
</div>
No dark: prefix needed — the CSS variables switch automatically.
Common Pitfalls
Pitfall 1: Defining Variables in :root/.dark Instead of @theme
/* WRONG — variables work but NO utilities are generated */
:root {
--color-surface: #ffffff;
}
.dark {
--color-surface: #0f172a;
}
You must define the base values in @theme so Tailwind generates bg-surface, text-surface, etc.
Pitfall 2: Using @utility with :root-Defined Variables
/* WRONG — LightningCSS resolves var() at build time */
:root {
--color-surface: #ffffff;
}
@utility bg-surface {
background-color: var(--color-surface);
}
/* Output: .bg-surface { background-color: rgb(255, 255, 255); } — HARDCODED! */
LightningCSS (bundled with TW4) sees the :root definition, statically resolves var(--color-surface) to #ffffff, and inlines it. The .dark override is ignored because the utility no longer references the variable.
Pitfall 3: Using @theme inline for Dark-Mode Tokens
/* WRONG — inline injects literal values, no var() */
@theme inline {
--color-surface: #ffffff;
}
/* Output: .bg-surface { background-color: #ffffff; } — no CSS variable at all */
Pitfall 4: Forgetting @custom-variant dark
Without it, all dark: classes in your components compile to:
@media (prefers-color-scheme: dark) {
.dark\:bg-slate-900 { ... }
}
This only responds to the OS-level dark mode setting, not your JavaScript-toggled .dark class.
Pitfall 5: Tailwind's Built-in Colors vs Custom Colors
Tailwind's built-in colors (blue-500, slate-900, etc.) always use var() in utilities because they're defined in Tailwind's internal theme. Your custom @theme colors behave the same way — they use var() too, as long as you use @theme (not @theme inline).
Diagnostic Checklist
When dark mode isn't working, check in this order:
-
Is
.darkon<html>?document.documentElement.classList.contains('dark') -
Are CSS variables switching?
getComputedStyle(document.documentElement).getPropertyValue('--color-surface') // Should be different in light vs dark -
Do utilities use
var()or hardcoded values? Check the compiled CSS (.next/dev/static/chunks/*.cssin Next.js):/* GOOD */ .bg-surface { background-color: var(--color-surface); } /* BAD */ .bg-surface { background-color: rgb(255, 255, 255); } -
Do
dark:variants use class or media query?/* GOOD */ .dark\:bg-slate-900:where(.dark, .dark *) { ... } /* BAD */ @media (prefers-color-scheme: dark) { .dark\:bg-slate-900 { ... } } -
Is the browser caching stale CSS? Open a fresh browser tab/incognito window. Dev server HMR doesn't always invalidate CSS.
Replacing Hardcoded Colors with Semantic Tokens
When migrating an existing project, replace Tailwind color classes with semantic tokens:
| Old Class | Semantic Token | Purpose |
|---|---|---|
bg-white | bg-surface | Primary background |
bg-gray-50 | bg-surface-secondary | Secondary background |
text-gray-900 | text-text-primary | Primary text |
text-gray-600 | text-text-secondary | Secondary text |
text-gray-400 | text-text-muted | Muted/placeholder text |
border-gray-200 | border-border | Default borders |
border-gray-300 | border-border-strong | Emphasized borders |
divide-gray-200 | divide-border | Divider lines |
For status colors (red, green, yellow) that need different shades in dark mode, use explicit dark: variants:
<span className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
Active
</span>
Framework-Specific Notes
Next.js (App Router)
- Add
<meta name="theme-color" content="..." media="(prefers-color-scheme: ...)">in layout.tsx - Place the dark mode toggle script in
<head>to prevent FOUC - After modifying
globals.css, you may need to clear.next/and restart the dev server — Turbopack sometimes caches stale CSS - Check compiled CSS in
.next/dev/static/chunks/to verifyvar()references
PostCSS Config
Ensure @tailwindcss/postcss is configured:
// postcss.config.mjs
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};