Skip to main content

Command Palette

Search for a command to run...

Implementing Dark Mode in Tailwind CSS (Without Fighting dark: Everywhere)

A practical dark mode setup for Tailwind in a Next.js app

Published
5 min read
Implementing Dark Mode in Tailwind CSS (Without Fighting dark: Everywhere)

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:

  1. If the user hasn’t chosen a theme yet, follow the system preference.

  2. When the user clicks a toggle button, switch between light and dark mode.

  3. 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 .dark exists 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 .dark class 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

Edit YeonhaPark/darkmode-demo/main


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 🌙☀️