Tailwind v4 Dark Mode: Setup, Toggle & Fixes (2026)
June 15, 2026
In Tailwind CSS v4 the darkMode config option no longer applies by default — v4 is CSS-first. Enable class-based dark mode by adding @custom-variant dark (&:where(.dark, .dark *)); to your CSS after @import "tailwindcss";, then toggle a dark class on <html>. Without it, dark: only follows the system preference.
TL;DR
Tailwind CSS v4 still ships the dark: variant, but it moved configuration out of tailwind.config.js and into your CSS. By default dark: tracks the operating system via prefers-color-scheme with zero JavaScript.1 To drive dark mode from a dark class instead, add one line — @custom-variant dark (&:where(.dark, .dark *)); — to your stylesheet.1 The reason most "Tailwind v4 dark mode not working" reports happen is that the old darkMode: 'class' config key is no longer part of v4's CSS-first model, so upgraded projects lose their class toggle until that line is added.2 This guide is verified against Tailwind CSS v4.3.13 and every CSS snippet was compiled and checked on 15 June 2026.
What you'll learn
- Why
dark:stops working after upgrading to Tailwind v4, and the one-line fix - Where dark mode is configured now that v4 is CSS-first and
tailwind.config.jsisn't loaded by default - How the default
prefers-color-schemebehavior works without any JavaScript - How to add a class-based dark mode toggle and persist it in
localStorage - How to prevent the flash of the wrong theme (FOUC) with an inline
<head>script - How to build a three-way light / dark / system toggle
- How to use a
data-themeattribute instead of a class - The most common Tailwind v4 dark mode mistakes, with fixes
Why is dark mode "not working" in Tailwind v4?
Most "Tailwind v4 dark mode not working" cases are caused by one change: the darkMode configuration key is no longer part of the default setup. Tailwind v4 is CSS-first, and a tailwind.config.js file is not loaded automatically, so darkMode: 'class' has no effect unless you explicitly opt back into a legacy config with the @config directive.2 Your dark:bg-gray-900 utilities still compile — they just default to the system media query, so toggling a .dark class does nothing until you opt in.
There is a second, sneakier cause. The automatic upgrade tool (@tailwindcss/upgrade) has, in some projects, rewritten the old class-based config into a broken variant such as @custom-variant dark (@media not print { .dark & }), which does not behave like a class toggle. If you upgraded and your toggle silently stopped working, open your CSS and check what the variant was rewritten to.4
To confirm what your build is actually producing, look at the compiled CSS. With no custom variant, a dark: utility compiles to a prefers-color-scheme media query:
/* dark:bg-gray-900 with NO @custom-variant — system-driven */
.dark\:bg-gray-900 {
@media (prefers-color-scheme: dark) {
background-color: var(--color-gray-900);
}
}
If you want a class to control the theme and you still see @media (prefers-color-scheme: dark) in the output, the variant override is missing or malformed.
Where do you configure dark mode in Tailwind v4?
You configure dark mode in your CSS, not in a JavaScript config file. Open the stylesheet where you import Tailwind and override the dark variant directly below the import:1
/* app.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
That single @custom-variant line is the entire class-based setup. After adding it, dark: utilities apply whenever a dark class appears earlier in the HTML tree:1
<html class="dark">
<body>
<div class="bg-white dark:bg-gray-900">
<!-- styled for dark mode -->
</div>
</body>
</html>
You may see other tutorials write the override as &:is(.dark *). It works, but :is() adopts the specificity of its most specific argument, which raises the specificity of every dark: utility and can cause confusing override bugs later. The official &:where(.dark, .dark *) form uses :where(), which always has zero specificity, so your dark utilities stay exactly as specific as their light counterparts.5 Prefer the official form.
If you are setting up a fresh project rather than fixing an existing one, install the v4 packages first36 and import Tailwind once in your entry CSS:
npm install tailwindcss @tailwindcss/vite
// vite.config.ts
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
});
/* src/style.css — the single @import replaces the old base/components/utilities trio */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
For a deeper walkthrough of the CSS-first setup, the @theme directive, and @source paths, see the companion Tailwind v4 + Vite from scratch guide.
Does Tailwind v4 dark mode work without JavaScript?
Yes. By default, before you add any @custom-variant override, the dark: variant is driven entirely by the prefers-color-scheme media feature, so it follows the operating system with no JavaScript and no class at all.1 If your only requirement is "respect the user's OS theme," you do not need a toggle, a script, or localStorage — just write your dark: utilities and ship:
<!-- Follows the OS theme automatically, zero JS -->
<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<h1 class="text-2xl font-bold">Zero Gravity</h1>
<p class="text-gray-500 dark:text-gray-400">Writes in any orientation.</p>
</div>
prefers-color-scheme is supported across all current browsers, so this path is safe to use as a baseline.7 Add the @custom-variant override only when you want users to flip the theme manually.
How do you add a class-based dark mode toggle?
To let users switch themes, add or remove the dark class on the <html> element and persist the choice. With the @custom-variant dark (&:where(.dark, .dark *)); line in place, a minimal toggle looks like this:
<button id="theme-toggle" type="button"
class="rounded-md border px-3 py-2 dark:border-gray-700">
Toggle theme
</button>
// theme-toggle.js
const root = document.documentElement;
document.getElementById("theme-toggle").addEventListener("click", () => {
const isDark = root.classList.toggle("dark");
localStorage.theme = isDark ? "dark" : "light";
});
This is framework-agnostic: the same classList.toggle("dark") works in plain HTML, Next.js, Astro, Vue, or Svelte, because Tailwind only cares whether the dark class exists on an ancestor element. The localStorage write is what makes the choice survive a reload — but on its own it introduces a flash, which the next section fixes.
How do you prevent the dark mode flash (FOUC)?
The flash of the wrong theme happens because your toggle script runs after the browser has already painted the page in the default theme. The fix is to set the dark class before first paint by inlining a tiny script in the <head>, above your stylesheet:1
<head>
<!-- Runs before paint — no flash of the wrong theme -->
<script>
if (
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
</script>
<link rel="stylesheet" href="/style.css" />
</head>
Because this script is inline and synchronous in the <head>, it executes before the body renders, so the correct theme is applied on the very first paint. Keep it inline — loading it as an external file reintroduces the flash, since the browser may paint before the file arrives. In a server-rendered app you can achieve the same result by rendering class="dark" on <html> from the server based on a stored preference, which is the SSR-friendly pattern for frameworks like the one in the Next.js streaming and caching guide.1
How do you support light, dark, and system themes?
A complete toggle offers three states — explicit light, explicit dark, and "follow my system." Tailwind's documentation models this with three localStorage operations: set theme to "light", set it to "dark", or remove it entirely to fall back to the OS.1 The official initialization expression handles all three:1
// Best inlined in <head> to avoid FOUC.
// dark when: explicit "dark", OR no stored choice AND the OS prefers dark.
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches),
);
Wire each button to the matching localStorage operation, then re-run the toggle expression:
// theme-controls.js
function applyTheme() {
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches),
);
}
document.getElementById("light").onclick = () => { localStorage.theme = "light"; applyTheme(); };
document.getElementById("dark").onclick = () => { localStorage.theme = "dark"; applyTheme(); };
document.getElementById("system").onclick = () => { localStorage.removeItem("theme"); applyTheme(); };
// Keep "system" mode live when the OS theme changes while the page is open
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (!("theme" in localStorage)) applyTheme();
});
The matchMedia change listener is the detail most tutorials skip: in "system" mode, the page should update if the user switches their OS theme while your tab is open. window.matchMedia and its change event are supported in all current browsers.8
How do you use a data attribute instead of a class?
If you prefer data-theme="dark" over a class — common in design systems that already use data attributes for theming — override the variant with an attribute selector instead:1
@import "tailwindcss";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
<html data-theme="dark">
<body>
<div class="bg-white dark:bg-black"><!-- ... --></div>
</body>
</html>
Everything else — the toggle, the FOUC script, the three-state logic — stays the same; you just set document.documentElement.dataset.theme = "dark" instead of toggling a class. The :where() wrapper keeps specificity at zero here too.5
Common Tailwind v4 dark mode mistakes (and fixes)
Each row below is a real failure mode pulled from the issues people file after upgrading.
| Symptom | Cause | Fix |
|---|---|---|
dark: ignores the .dark class | No @custom-variant override — dark: defaults to the media query | Add @custom-variant dark (&:where(.dark, .dark *)); after @import "tailwindcss";1 |
| Toggle worked in v3, broke after upgrade | darkMode: 'class' isn't part of v4's CSS-first config | Move the setting into CSS via @custom-variant2 |
| Variant looks set but still fails | Upgrade tool emitted @custom-variant dark (@media not print { .dark & }) | Replace it with the official &:where(.dark, .dark *) form4 |
| Dark utilities override unexpectedly | Used &:is(.dark *), which raises specificity | Switch to &:where(...) for zero specificity5 |
| Page flashes light then dark on load | Toggle script runs after first paint | Inline the theme script in <head> above your stylesheet1 |
| "System" mode ignores live OS changes | No matchMedia change listener | Add a change listener that re-applies when no choice is stored8 |
Bottom line
Tailwind v4 did not remove dark mode — it moved the switch. The default dark: variant follows the operating system with no code, and one line of CSS, @custom-variant dark (&:where(.dark, .dark *));, upgrades that to a class you control.1 If your toggle broke after upgrading, it is almost always the missing darkMode config key, fixed by that same line.2 Add the inline <head> script for a flash-free load, the three-state logic for a real toggle, and you have a production dark mode that works in any framework.
Next, lock down the rest of your v4 setup with the Tailwind v4 + Vite CSS-first guide, and if you are new to the underlying cascade rules that make :where() matter, start with the CSS fundamentals primer.
Footnotes
-
"Dark mode — Core concepts," Tailwind CSS documentation (v4.3), covering the default
prefers-color-schemebehavior, the@custom-variant dark (&:where(.dark, .dark *))override, the data-attribute variant, and the inline-headFOUC script. https://tailwindcss.com/docs/dark-mode ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 -
"Upgrading to Tailwind v4: Missing Defaults, Broken Dark Mode, and Config Issues," tailwindlabs/tailwindcss Discussion #16517 — documents dark-mode breakage after upgrading to v4's CSS-first model, where the
darkModeconfig key no longer applies by default. https://github.com/tailwindlabs/tailwindcss/discussions/16517 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
tailwindcss on npm — version 4.3.1, published 2026-06-12. https://www.npmjs.com/package/tailwindcss ↩ ↩2
-
"[v4] upgrade does not work for dark mode variant with media query," tailwindlabs/tailwindcss Issue #16171 — the automatic upgrade can emit a non-functional
@custom-variant dark (@media not print { .dark & }). https://github.com/tailwindlabs/tailwindcss/issues/16171 ↩ ↩2 -
":where() always has 0 specificity, whereas :is() takes the specificity of its most specific argument." MDN Web Docs,
:where(). https://developer.mozilla.org/en-US/docs/Web/CSS/:where ↩ ↩2 ↩3 ↩4 -
@tailwindcss/vite on npm — version 4.3.1, the official v4 Vite plugin. https://www.npmjs.com/package/@tailwindcss/vite ↩
-
"prefers-color-scheme," MDN Web Docs — a CSS media feature supported across current browsers. https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme ↩
-
"Window.matchMedia()," MDN Web Docs — returns a
MediaQueryListthat emits achangeevent when the match state changes. https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia ↩ ↩2