Tailwind v4 Dark Mode Not Working?
Quick answer: In Tailwind v4, dark: follows the operating system’s prefers-color-scheme by default, so adding a dark class to your page does nothing. To toggle with a class, add @custom-variant dark (&:where(.dark, .dark *)); to the CSS file that imports Tailwind, put class="dark" on <html>, and set that class from a small inline script in <head> so the page doesn’t flash light before your JavaScript runs.
Most “dark mode not working” reports after an upgrade come from the same place. Tailwind v3 read a darkMode option from tailwind.config.js. Tailwind v4 has no JavaScript config by default, so that option quietly stopped existing for anyone who deleted the file or never loaded it. The dark: classes still compile. They just compile to a media query, and your toggle button changes a class that nothing is listening for.
What the other guides get wrong about darkMode in v4
You’ll read that the darkMode key “is ignored in Tailwind 4.” That’s only half true. v4 doesn’t detect a tailwind.config.js automatically, but the upgrade guide still supports loading one with @config, and when you do, the compatibility layer reads darkMode and registers the variant for you. The source for that layer is short enough to read in a minute, and it explains a bug people hit after migrating:
darkMode value (loaded via @config) | What v4 generates for dark: | Matches the element with the class? |
|---|---|---|
| not set, or no config loaded | @media (prefers-color-scheme: dark) | n/a, ignores classes |
'media' | @media (prefers-color-scheme: dark) | n/a |
'selector' | &:where(.dark, .dark *) | Yes |
'class' | &:is(.dark *) | No, descendants only |
['variant', '…'] | your selector, used as given | depends on what you wrote |
So a v3 project with darkMode: 'class' that you migrate and load through @config mostly works, except for dark: utilities on the element that carries the class. If <html class="dark"> also has dark:bg-gray-950 or dark:scheme-dark on it, those never apply, because :is(.dark *) means “inside something with .dark.” The 'selector' strategy, which replaced 'class' in v3.4.1, is the one that matches both. The clean fix in v4 is to drop the config option and write the variant in CSS.
The setup that works
Three pieces. The variant, the class, and a script that sets the class before first paint.
/* app.css: the file that does @import "tailwindcss" */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
<!doctype html>
<html lang="en">
<head>
<script>
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
);
</script>
<link rel="stylesheet" href="/app.css">
</head>
That script is the one from the Tailwind dark mode docs. It respects a saved choice, falls back to the OS setting, and runs synchronously in <head>, which is the whole point. Your toggle button then writes localStorage.theme = "dark" or "light", or removes the key to go back to following the system, and calls the same classList.toggle line.
If you’d rather use an attribute, the docs give the equivalent: @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); and <html data-theme="dark">. Pick one. Mixing a class-based variant with a script that sets data-theme is a surprisingly common way to end up here.
Dark mode fix generator
Answer four questions about your setup. The output is the CSS and the <head> script for exactly that setup, plus what was wrong with it.
Diagnosis
app.css
<head> script
Every reason dark: classes don’t apply
1. There’s no custom variant, so dark: follows the OS
The default dark variant is @media (prefers-color-scheme: dark). Your toggle adds .dark, the media query doesn’t care, and the page only goes dark when the operating system does. You can confirm it in a second: switch your OS to dark mode, or in Chrome DevTools open the Rendering panel and emulate prefers-color-scheme: dark. If the page flips, this is your bug. Add the @custom-variant line.
2. The variant is in a file Tailwind never compiles
@custom-variant has to live in the stylesheet Tailwind processes, the one with @import "tailwindcss", or a file that one imports. Putting it in a component’s CSS module or a second stylesheet linked separately does nothing. Also check you wrote @custom-variant. @variant is a different directive in v4: it applies an existing variant inside your own CSS, as in .card { @variant dark { background: black; } }, and the two get confused in copied snippets.
3. The class is on the wrong element
With &:where(.dark, .dark *), an element is dark if it has the class or sits inside something that does. Put the class on <body> and anything styling <html> misses out, which is where the page background and color-scheme usually live. Put it on an app wrapper <div> and you lose more: modals, toasts and dropdowns that a framework portals to the end of <body> render outside the wrapper and stay light. Put it on <html>. That’s the only element every other element is inside.
4. Your own CSS beats the utility
The :where() in the variant adds zero specificity, so dark:bg-gray-900 has the same specificity as bg-white and wins because Tailwind emits variant utilities later. That part is fine. The trap is cascade layers. v4 puts utilities in @layer utilities, and any unlayered rule beats every layered rule no matter how specific it is. A plain body { background: #fff; } in your stylesheet, or a third-party CSS file, will override dark:bg-gray-900 on the body every time. Wrap your own base styles in @layer base { … } and the order goes back to what you expect. If you want to see the specificity side of this drawn out, the specificity graph post covers it.
5. The page flashes light before going dark
A flash of the wrong theme means the class is being set after first paint: in a useEffect, a deferred script, or a module bundle. The browser has already painted the light version by then. The fix is the synchronous inline script above, placed in <head> before your stylesheet. It is a few hundred bytes, so blocking on it costs nothing measurable. In frameworks that render <html> on the server, the server doesn’t know the user’s choice, so the client will see a class it didn’t render. A cookie, so the server can render the right class, is the robust fix. Libraries such as next-themes handle the script for you.
6. A tailwind.config.js exists but isn’t loaded
v4 doesn’t look for it. Unless your CSS says @config "../tailwind.config.js";, nothing in that file runs, including darkMode, your theme.extend colors and your plugins. Either add the @config line or, better, move the settings into CSS. And if you do load it, remember from the table above that darkMode: 'class' compiles to the older descendant-only selector. Change it to 'selector'.
7. Your theme colors were never given dark values
If you use semantic tokens, such as bg-surface and text-ink, the dark: variant isn’t involved at all. The token has to change value. In v4, @theme variables are real CSS custom properties, so redefine them under the dark selector:
@theme {
--color-surface: #ffffff;
--color-ink: #0f172a;
}
@layer base {
.dark {
--color-surface: #0b0d12;
--color-ink: #e5e7eb;
}
}
One exception catches people out. With @theme inline, Tailwind writes the value into the utility instead of var(--color-surface), so redefining --color-surface later has no effect. Point the inline theme at an ordinary variable instead (@theme inline { --color-surface: var(--surface); }) and redefine --surface for dark.
8. Native controls, scrollbars and autofill stay light
Tailwind colors don’t touch what the browser draws itself. Scrollbars, checkboxes, date pickers, <select> menus and the default canvas follow the color-scheme property. Set it with the class: <html class="scheme-light dark:scheme-dark">, using the color-scheme utilities. This is also where cause 3 and cause 6 bite again: dark:scheme-dark on <html> only works when the variant matches the element that has the class.
9. The class name is built at runtime
Tailwind finds classes by scanning your source files as plain text. `dark:bg-${shade}` never appears in full, so it never gets generated. Write the full class names out, or list them with @source inline().
How to check which cause you have
Open DevTools, select an element that should be dark and look at the Styles pane. You’re looking for the dark: rule and the selector it compiled to.
- If the rule sits inside
@media (prefers-color-scheme: dark), there’s no custom variant (cause 1, 2 or 6). - If the selector is
:where(.dark, .dark *)but the rule is greyed out as not matching, the class isn’t on this element or an ancestor (cause 3). - If the rule matches but is struck through, find the rule that beat it. An unlayered rule is cause 4.
- If there is no
dark:rule at all, the class wasn’t generated (cause 9).
Then type this into the console to see what the page thinks:
({
htmlClass: document.documentElement.className,
dataTheme: document.documentElement.dataset.theme,
saved: localStorage.theme,
osDark: matchMedia("(prefers-color-scheme: dark)").matches,
colorScheme: getComputedStyle(document.documentElement).colorScheme,
})
Checking a dozen elements this way gets old fast. CSS DNA, a browser extension for inspecting CSS, lets you click any element and copy its computed CSS, with the winning values already resolved, so you can compare an element in light and dark without walking the Styles pane rule by rule. Add CSS DNA to Chrome (free).
A complete three-way toggle
Light, dark and “follow the system” is what most people want. This is all of it, with no framework:
<select id="theme" aria-label="Color theme">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<script>
const mq = matchMedia("(prefers-color-scheme: dark)");
const apply = () =>
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" || (!("theme" in localStorage) && mq.matches)
);
theme.value = localStorage.theme || "system";
theme.addEventListener("change", () => {
if (theme.value === "system") localStorage.removeItem("theme");
else localStorage.theme = theme.value;
apply();
});
mq.addEventListener("change", apply); // OS switches while the tab is open
</script>
The last line is the one people forget. Without it, a user on “System” whose OS flips to dark at sunset keeps a light page until they reload.
v3 and v4 side by side
| Tailwind v3 | Tailwind v4 | |
|---|---|---|
Default dark: | media query | media query |
| Class toggle | darkMode: 'selector' in config | @custom-variant dark (&:where(.dark, .dark *)); |
| Attribute toggle | darkMode: ['selector', '[data-theme="dark"]'] | @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); |
| Config file | found automatically | only with @config |
| Dark values for tokens | CSS variables you wire up yourself | redefine @theme variables under .dark |
color-scheme | plain CSS | scheme-light dark:scheme-dark |
If you’re mid-migration and want the rest of the v3 config moved over properly, converting a theme to a v4 @theme block walks through the namespaces. For dark palettes specifically, picking dark shades in OKLCH keeps the lightness steps even, and it’s worth running the result through a contrast check, since dark themes fail on muted grey text more often than light ones do.
Frequently asked questions
Why is Tailwind v4 dark mode not working?
Usually because v4’s dark: variant uses prefers-color-scheme by default and ignores classes. If you toggle a dark class, add @custom-variant dark (&:where(.dark, .dark *)); to the CSS file that imports Tailwind. If dark mode works but flashes, set the class with an inline script in <head>.
Does darkMode in tailwind.config.js still work in v4?
Only if you load the file with @config; v4 doesn’t detect it automatically. When loaded, 'selector' becomes :where(.dark, .dark *) and 'class' becomes :is(.dark *), which skips the element that has the class. Moving the setting into CSS with @custom-variant is simpler and is what the v4 docs show.
How do I stop the white flash before dark mode loads?
Set the class before the browser paints. A small synchronous script in <head>, ahead of the stylesheet, that reads localStorage and matchMedia("(prefers-color-scheme: dark)") does it. Setting the class in a framework effect or a deferred bundle always runs after the first paint, which is the flash you see.
Can I use data-theme instead of a dark class?
Yes. Define the variant as @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); and set data-theme="dark" on <html>. The selector and the attribute your script writes must match exactly, including the attribute name, which is the usual mistake when copying snippets between projects.
Why don’t my custom colors change in dark mode?
Semantic tokens like bg-surface need a second value, not a dark: prefix. Redefine the @theme variable inside a .dark rule in @layer base. If you declared the token with @theme inline, the utility contains the literal value and won’t see the redefinition, so point it at a plain variable you can override.
Why are scrollbars and form inputs still light?
Browser-drawn parts of the page follow the CSS color-scheme property, not your Tailwind colors. Add scheme-light dark:scheme-dark to <html>, or color-scheme: dark in your .dark rule, and native scrollbars, checkboxes, selects and date pickers switch with the rest of the page.
See the CSS that actually won
Click any element to copy its computed CSS, read fonts and colors, and preview a site’s design system, all in your browser.
Add CSS DNA to Chrome (free)Free: element CSS, eyedropper, ranked palette. Pro, $5/mo after a 7-day trial: exports, DESIGN.md, CSS audit. No account.