Dark Mode in Tailwind CSS v4: The Right Way
Tailwind v4 changed how dark mode works. Here's how to set up class-based dark mode with next-themes and avoid hydration mismatches in Next.js 15.
IStarry
2 min read
What Changed in Tailwind v4
Tailwind CSS v4 made a subtle but important change to dark mode. In v3, you configured dark mode in tailwind.config.ts:
// Tailwind v3
export default {
darkMode: "class",
// ...
};In v4, there's no config file at all. Instead, dark mode defaults to the prefers-color-scheme media query. To switch to class-based mode, you override the dark variant in your CSS:
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));Why Class-Based Mode?
Media-query-based dark mode (prefers-color-scheme: dark) works well for sites that only follow the OS setting. But for a blog with a manual theme toggle, you need class-based mode.
The next-themes library works by adding class="dark" to the <html> element:
<html class="dark" lang="en">Tailwind's dark: prefix then matches elements inside this class:
<div class="bg-white dark:bg-neutral-950">
This div adapts to the theme
</div>Avoiding Hydration Mismatches
The trickiest part of dark mode in Next.js is avoiding the flash of wrong theme. Here's the solution:
- Add
suppressHydrationWarningto<html> - Use
next-themeswhich injects an inline<script>before React hydrates - In toggle components, use a
mountedstate pattern:
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className="h-9 w-9" />; // placeholder
}This ensures the server and client render identical HTML on the first pass.
The @variant Syntax in Detail
The @variant dark (&:where(.dark, .dark *)) line uses the :where() pseudo-class. This is important because :where() has zero specificity — it won't accidentally override other styles.
Compare:
| Selector | Specificity |
|---|---|
.dark .dark\:text-white | 0,2,0 |
:where(.dark, .dark *) .dark\:text-white | 0,1,0 |
Using :where() keeps the CSS cascade predictable.
Related Articles
不换框架的界面改版:拒绝 Next.js、设计令牌、和被实测推翻的 1.6:1
需求方要求“优化界面,加上 Next.js”。本文记录我为什么拒绝它、为什么只引入一个依赖、设计令牌如何落地,以及一次口算被 WCAG 实测推翻(1.6:1 实为 1.16:1)后我对问题的重判。