Implementing Dark Mode in Tailwind CSS (Without Fighting dark: Everywhere)
A practical dark mode setup for Tailwind in a Next.js app

In this article, we’ll look at how to implement light mode and dark mode in a Tailwind CSS project—without sprinkling dark: modifiers on every single class.
Instead of writing things like:
<div class="bg-white text-black dark:bg-black dark:text-white">
we’ll apply color modes broadly and consistently using CSS variables, while keeping the toggle logic simple and predictable.
The Plan
Before diving into code, here’s the behavior we want:
If the user hasn’t chosen a theme yet, follow the system preference.
When the user clicks a toggle button, switch between light and dark mode.
Persist the user’s choice, so refreshing the page doesn’t reset the theme.
Simple and practical. Let’s get started.
Create a Next.js App
npx create-next-app@latest
Make sure Tailwind CSS is enabled during setup.
Default Styling (What You Get Out of the Box)
After the project is created, you’ll see a globals.css file similar to this:
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
Here, prefers-color-scheme: dark is a media query that matches the user’s OS or browser setting.
It’s great for automatic defaults, but there’s a problem.
With this approach, you cannot implement an in-app toggle button.
The user would have to change their OS setting just to switch themes.
That’s not what we want.
Switching to a Class-Based Dark Mode Strategy
Let’s replace the previous approach with a manual, class-driven strategy.
Updated globals.css
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@theme {
--color-background: var(--bg-background);
--color-foreground: var(--text-foreground);
--color-primary: var(--brand-primary);
}
:root {
/* Default (Light Mode) */
--bg-background: #ffffff;
--text-foreground: #0f172a; /* Slate-900 */
--brand-primary: #3b82f6; /* Blue-500 */
}
/* Dark Mode override */
.dark {
/* Dark Mode values */
--bg-background: #0f172a; /* Slate-900 */
--text-foreground: #f8fafc; /* Slate-50 */
--brand-primary: #60a5fa; /* Blue-400 */
}
Why Redefine dark: with @variant?
Tailwind already provides dark: by default—so why redefine it?
Because we want to change the strategy.
1. Default behavior: system preference
By default, Tailwind’s dark: variant relies on:
@media (prefers-color-scheme: dark)
Meaning:
“Apply dark styles only when the OS is in dark mode.”
This is automatic, but not controllable from inside the app.
2. Override behavior: class-based control
What we want instead is:
“Ignore the OS setting.
Apply dark styles only when.darkexists on the document.”
That’s exactly what this line does:
@variant dark (&:where(.dark, .dark *));
Now, dark: styles apply only when the element itself or one of its ancestors has the .dark class.
Why This Approach Works: Overriding Design Tokens at the Root
The key idea behind this approach is not styling components directly, but overriding design tokens at the root of the document.
Instead of saying:
“This button is dark”
“This card is light”
we are saying:
“The entire document is currently in dark mode.”
By applying the .dark class to the top-level DOM element (usually <html>), we change the context in which all components exist.
CSS Variables as the Single Source of Truth
All colors in this setup are defined as CSS variables:
--bg-background
--text-foreground
--brand-primary
Components never care whether the page is in light or dark mode.
They simply consume variables:
background-color: var(--bg-background);
color: var(--text-foreground);
This creates a clear separation of concerns:
Components describe structure and layout
Theme variables describe visual identity
Why the .dark Class Lives at the Top
When .dark is applied to the root element, this rule becomes active:
.dark {
--bg-background: #0f172a;
--text-foreground: #f8fafc;
}
Because CSS variables follow normal cascade and inheritance rules, every descendant automatically receives the dark-mode values—without any additional selectors or modifiers.
Toggling the Theme in JavaScript
Here’s a simple toggle function:
const toggleTheme = () => {
const html = document.documentElement;
if (html.classList.contains("dark")) {
// Dark → Light
html.classList.remove("dark");
localStorage.setItem("theme", "light");
} else {
// Light → Dark
html.classList.add("dark");
localStorage.setItem("theme", "dark");
}
};
This does two things:
Toggles the
.darkclass on<html>Saves the user’s choice in
localStorage
(Optional) React to System Theme Changes
We still want a good default behavior:
- Follow the system theme only if the user hasn’t made a manual choice
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (!localStorage.getItem("theme")) {
if (mediaQuery.matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
This keeps the experience respectful of user intent.
Preventing Flash of Incorrect Theme (FOUC)
One last detail:
Without care, the page may briefly flash the wrong theme on initial load.
To fix this, inject a small script before React renders.
app/layout.tsx
const themeInitScript = `
(function() {
const userTheme = localStorage.getItem("theme");
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (userTheme === "dark" || (!userTheme && systemTheme)) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
})();
`;
Then include it in <head>:
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
Now the correct theme is applied before the first paint.
CodeSandbox
Final Thoughts
This implementation intentionally avoids flashy techniques or heavy abstractions.
Instead of introducing additional libraries or complex state management, we relied on core CSS features—specifically CSS variables and the cascade—combined with Tailwind’s tooling to solve the problem in a simple and predictable way.
Dark mode doesn’t have to be a JavaScript-heavy feature.
By letting CSS do what it does best—inheritance, overrides, and context—we can build a clean, maintainable theme system with minimal code.
Happy theming 🌙☀️


