Most dark mode tutorials still tell you to open tailwind.config.js and set darkMode: 'class'. If you’re on Tailwind v4, that file probably doesn’t exist — configuration moved into CSS, and dark mode setup changed with it.
This guide covers dark mode as it actually works today: the v4 way, the v3 way (still on most existing projects), a toggle that doesn’t flash the wrong theme on load, and the design decisions that separate a dark mode people use from one they turn off. We’ll also look at when you don’t need Tailwind’s approach at all.
First decision: who controls the theme?
There are three ways a site can decide between light and dark, and picking the wrong one is the most common dark mode mistake:
| Approach | How it works | Best for |
|---|---|---|
| System-following | prefers-color-scheme media query; no toggle | Content sites, docs, blogs |
| Manual toggle | User clicks; you store the choice | Apps where users live for hours |
| Three-state (recommended) | Light / Dark / System, defaulting to System | Anything with a toggle |
The three-state version is the modern standard for a reason: users who never touch the toggle get their OS preference automatically, and users who want an override get one that persists. Apple’s design guidance makes the underlying point well — theme choice is an aesthetic preference for most users, not a function of ambient light, so respect the preference they’ve already expressed at the system level unless they explicitly override it.
Dark mode in Tailwind v4
In v4, the dark: variant works out of the box with zero configuration, keyed to prefers-color-scheme:
<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
Follows the OS setting automatically
</div>
If you want manual toggling (the class strategy), you override the variant in your CSS file — no JavaScript config involved:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Now dark:* utilities apply whenever a .dark class is present on an ancestor (conventionally <html>), and your JavaScript controls the theme by toggling that class. Custom dark palette colors live in @theme as CSS variables rather than a JS object:
@theme {
--color-surface: #ffffff;
--color-surface-dark: #0f172a;
}
Migrating an older project? Our free Tailwind v4 converter translates v3 config-based setups to the v4 CSS-first syntax.
Dark mode in Tailwind v3
Plenty of production codebases are still on v3, where dark mode is a config option:
// tailwind.config.js
module.exports = {
darkMode: 'media', // follow the OS — the default
// or
darkMode: 'class', // toggle via a .dark class ('selector' in v3.4+)
}
Everything else — the dark: prefix, the toggle logic below — works the same in both major versions. The utilities are identical; only where the configuration lives changed.
Styling with the dark: variant
The prefix composes with every other variant, which is where Tailwind’s approach earns its keep — hover, focus, and breakpoint states in dark mode are just stacked prefixes:
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-lg">
<h3 class="text-gray-900 dark:text-white text-xl font-semibold">Card Title</h3>
<p class="text-gray-600 dark:text-gray-300 mt-2">Card content</p>
<button class="mt-4 bg-blue-500 dark:bg-blue-600 text-white px-4 py-2 rounded
hover:bg-blue-600 dark:hover:bg-blue-700
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400">
Action Button
</button>
</div>
A consistent pairing system keeps hundreds of these decisions coherent. Decide once, apply everywhere:
| Element | Light | Dark |
|---|---|---|
| Page background | bg-white | dark:bg-gray-900 |
| Cards / panels | bg-gray-50 | dark:bg-gray-800 |
| Body text | text-gray-900 | dark:text-gray-100 |
| Secondary text | text-gray-600 | dark:text-gray-400 |
| Borders | border-gray-200 | dark:border-gray-700 |
| Primary buttons | bg-blue-500 | dark:bg-blue-600 |
If your markup is filling up with repeated dark: pairs, that’s the signal to move the pair into a component (or a semantic CSS variable) rather than pasting the same six utilities on every card.
A toggle that doesn’t flash
The classic dark mode bug: the page loads light, then snaps dark a frame later. It happens because the theme check runs after the first paint. The fix is a tiny inline script in <head>, before any stylesheet-dependent rendering:
<script>
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
);
</script>
Note the three-state logic baked in: an explicit localStorage.theme wins; otherwise the system preference decides. The toggle itself then just writes the choice:
function setTheme(theme) {
if (theme === "system") {
localStorage.removeItem("theme");
} else {
localStorage.theme = theme;
}
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
);
}
Toggle button essentials: it’s a <button> (keyboard-focusable for free), it has aria-label="Toggle dark mode", and its icon reflects the current state. Put it somewhere predictable — header or footer — not buried in a settings page.
One more line that most implementations miss:
:root { color-scheme: light; }
.dark { color-scheme: dark; }
color-scheme tells the browser about your theme, so scrollbars, form controls, and the default canvas render natively dark instead of glaring white inside your dark UI.
Do you even need Tailwind for this?
We merged an older post into this one that compared four dark mode approaches, and the comparison is still useful — Tailwind is one good answer, not the only one:
| Method | Setup | Flexibility | Watch out for |
|---|---|---|---|
Native CSS (prefers-color-scheme + variables) | Minimal | System-only, no toggle | Nothing — it’s the cleanest zero-JS option |
| CSS variables + JS toggle | Moderate | Total — supports any number of themes | Needs the same head-script FOUC fix |
Tailwind dark: variant | Minimal | High, utility-driven | Repeated pairs belong in components |
CSS filter: invert() | One line | Almost none | Inverts images, breaks brand colors, misses WCAG contrast — prototype-only |
If you’re not using Tailwind, the CSS-variables pattern is the same idea with different syntax — define --bg/--text per theme on :root and .dark, and everything downstream reads the variables. The inversion filter is the only approach to actively avoid in production: it’s a mathematical transform, not a design, and it mangles photos, logos, and carefully-chosen contrast in one stroke.
The design details that make dark mode good
Flipping colors is the easy 80%. These are the details users actually notice:
- Don’t use pure black.
#000backgrounds with light text produce harsh contrast and visible smearing on OLED screens. Dark grays (gray-900, roughly#0f172a–#1a202c) read as more comfortable — every major dark UI does this. - Meet contrast ratios in both themes. WCAG requires 4.5:1 for normal text and 3:1 for large text. Dark mode fails this more often than light mode, usually on secondary text (
gray-500ongray-900is a frequent offender). Hoverify’s SEO tool includes a WCAG contrast audit with suggested fixes, and its color eyedropper grabs the exact rendered values when you need to check a pairing by hand. - Mind light-on-dark readability. Many people — including a large share of people with astigmatism — find light text on dark backgrounds harder to read than the reverse. That’s the strongest argument for the three-state toggle: dark mode should be an option, never forced.
- Rethink elevation. Shadows are nearly invisible on dark backgrounds. Dark UIs signal elevation by making raised surfaces slightly lighter (
gray-800cards on agray-900page), not by darker shadows. - Check your images. Illustrations with white backgrounds glow harshly in dark mode. Serve theme-aware assets with
dark:hidden/dark:blockpairs, or add a subtledark:brightness-90to soften photos. - Skip the theme-change transition on everything. A global
transition: background-coloron all elements causes a sluggish, staggered repaint when themes switch. If you want polish, transition only the top-level surfaces — our CSS transitions guide covers doing that without jank.
Testing your dark mode
Toggle isn’t enough — verify like a user: switch OS-level theme with the site open (system-following should react live via the matchMedia listener), reload on every state (light, dark, system-dark) watching for flashes, and check both themes at mobile widths, where contrast problems and glowing images are most noticeable.
Curious how a site you admire handles its dark theme? Hoverify’s Inspector shows the applied classes and computed colors on hover, and if a site doesn’t offer dark mode, our force dark mode trick shows how to inject one with custom CSS.
Dark mode done well is one decision (who controls the theme), one script (the FOUC fix), one system (your color pairings), and a pass of design care. Tailwind makes the mechanical part nearly free — spend the time you save on the contrast checks.