My designer pinged me on a Friday afternoon: ‘dark mode looks broken on the pricing page.’ It was not broken, exactly. It was stuck. I had upgraded that project to Tailwind v4 a week earlier, and my old darkMode: 'class' line was sitting in a tailwind.config.js file that v4 was not really reading anymore. Every dark: utility had quietly stopped responding to the theme toggle. I had not noticed, because I had been testing in light mode like a professional.
That afternoon sent me into how dark mode actually works in Tailwind v4. The short version: it got simpler, and the simplicity is the part that trips you up. There is no darkMode key to set. The behavior you want now lives in your CSS, in one line. Here is the whole thing, including the part nobody warns you about.
How dark mode worked before v4
In Tailwind v3, dark mode was a config decision. You opened tailwind.config.js and picked a strategy:
// tailwind.config.js (Tailwind v3)
module.exports = {
darkMode: "class", // or "media"
content: ["./src/**/*.{html,js,jsx,ts,tsx}"],
theme: { extend: {} },
};
"media" meant dark: utilities followed the operating system through the prefers-color-scheme media query. "class" meant they activated whenever a .dark class appeared higher in the tree, which is what you needed for a manual toggle. You set it once and forgot about it for the life of the project.
v4 broke that habit, because v4 moved configuration out of JavaScript and into CSS. If you have already been through that migration, I wrote up what broke when I moved to Tailwind v4, and dark mode was not the only surprise waiting in there.
The v4 way: a custom variant in your CSS
In v4, the default is prefers-color-scheme. Import Tailwind, and dark: utilities already follow the OS. No config, nothing to switch on.
The catch is that the default is all you get for free. The moment you want a manual toggle, a button that flips the theme regardless of the OS, you override the dark variant yourself. That happens in your CSS entry file, with @custom-variant:
/* app.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
That one line is the whole replacement for darkMode: 'class'. It redefines what dark: means. Instead of “when the OS is dark”, it now means “when a .dark class exists on this element or an ancestor”. The :where() wrapper holds specificity at zero, so your utilities still override cleanly.
Your markup does not change at all:
<html class="dark">
<body>
<div class="bg-white dark:bg-black">...</div>
</body>
</html>
The official Tailwind dark mode docs cover this, but it is easy to skim past. It is one line on a page you read once, and then forget you read.
One thing worth knowing before you sprinkle dark: across every component: in v4 you can often skip it. If your colors live as theme variables, you can redefine those variables once under the .dark selector, in CSS, and most components just follow along. Writing dark:bg-black on every card works, but it scatters the theme across your markup. Redefining a --color-surface token under the dark selector keeps the decision in one file. I still use dark: for real one-off tweaks, but the variable approach is what keeps a large UI consistent without me chasing stray colors.
Class, data attribute, or media query
Three setups, and the choice is mostly about taste, not performance.
The class strategy above is what I reach for most. It is terse, and it is what nearly every Tailwind tutorial and component library assumes you are using.
If you would rather not put a state class on <html>, use a data attribute instead:
@import "tailwindcss";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
Now dark: responds to data-theme="dark". I like this one on projects that already drive theming through data attributes, because the theme then lives in the same place as every other piece of UI state.
And if a site only ever needs to follow the OS, do nothing. The v4 default already does that. I have shipped marketing sites where the entire “dark mode feature” was zero lines of code, and zero was the correct amount.
The flash-of-wrong-theme problem
Here is the part nobody warns you about, and the real cause of my Friday bug once the dead config line was sorted.
If you toggle dark mode with a class, something has to put that class on <html>. If you do that in a normal React effect, or a script at the end of <body>, the page first paints in light mode and then snaps to dark a moment later. That flash looks broken, and on a slow phone it is more than a moment.
The fix is to set the class before the browser paints, with a small blocking script in the <head>:
<head>
<script>
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
);
</script>
</head>
It reads the saved preference, falls back to the OS through window.matchMedia, and sets the class synchronously. Yes, it is a blocking inline script, and yes, that is fine here. It runs in well under a millisecond, and it is the price of never showing the wrong theme. Every theme library you might reach for is doing some version of this same trick under the hood.
A three-way toggle that respects the OS
Most real apps want three states, not two: light, dark, and “whatever the OS says”. The pattern that has held up for me stores an explicit choice in localStorage only when the user makes one, and treats the absence of a value as “follow the OS”.
// user picks light
localStorage.theme = "light";
// user picks dark
localStorage.theme = "dark";
// user picks "system": remove the override entirely
localStorage.removeItem("theme");
The head script above then does the right thing in all three cases, because it checks localStorage first and only consults matchMedia when there is no stored override. The mistake I made early was writing the literal string "system" into localStorage. It works, but then you are hand-maintaining three string states and all the comparisons that go with them. Removing the key is cleaner: a value present means an explicit choice, a value absent means inherit from the OS.
There is one more property worth setting that has nothing to do with Tailwind variants: color-scheme. Telling the browser color-scheme: dark makes it render native UI, scrollbars, form controls, date pickers, and the rest, in their dark variants. Without it you end up with a carefully themed dark page wearing a glaringly white scrollbar. Tailwind ships scheme-dark and scheme-light utilities for exactly this. I forgot it on two separate projects before the habit finally stuck.
What I would change this week
If you are on Tailwind v4 already, open your CSS entry file and check for a @custom-variant dark line. If it is not there and you have a theme toggle, that toggle is not working the way you think it is. That was my exact bug. Add the line, and the toggle starts responding again.
If you are still on v3, dark mode by itself is not a reason to upgrade. It is a fair preview of how v4 feels, though: less configuration, and more of the behavior sitting in plain CSS where you can actually see it. I keep notes on the front-end setups I ship over on my portfolio, and the pattern repeats everywhere, fewer config files and more defaults I can read at a glance. Dark mode in v4 is one line. Spend the hour you saved on picking colors that pass a real contrast check.