The Art of Dark Mode Implementation with Tailwind CSS

Dark mode in Tailwind v4 and v3: system-preference vs class toggling, a flash-free three-state switch, when to use plain CSS variables instead, and the design details most implementations miss.

Mar 5, 2025Updated Jul 20, 2026
The Art of Dark Mode Implementation with Tailwind CSS

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:

ApproachHow it worksBest for
System-followingprefers-color-scheme media query; no toggleContent sites, docs, blogs
Manual toggleUser clicks; you store the choiceApps where users live for hours
Three-state (recommended)Light / Dark / System, defaulting to SystemAnything 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:

ElementLightDark
Page backgroundbg-whitedark:bg-gray-900
Cards / panelsbg-gray-50dark:bg-gray-800
Body texttext-gray-900dark:text-gray-100
Secondary texttext-gray-600dark:text-gray-400
Bordersborder-gray-200dark:border-gray-700
Primary buttonsbg-blue-500dark: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:

MethodSetupFlexibilityWatch out for
Native CSS (prefers-color-scheme + variables)MinimalSystem-only, no toggleNothing — it’s the cleanest zero-JS option
CSS variables + JS toggleModerateTotal — supports any number of themesNeeds the same head-script FOUC fix
Tailwind dark: variantMinimalHigh, utility-drivenRepeated pairs belong in components
CSS filter: invert()One lineAlmost noneInverts 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. #000 backgrounds 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-500 on gray-900 is 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-800 cards on a gray-900 page), not by darker shadows.
  • Check your images. Illustrations with white backgrounds glow harshly in dark mode. Serve theme-aware assets with dark:hidden/dark:block pairs, or add a subtle dark:brightness-90 to soften photos.
  • Skip the theme-change transition on everything. A global transition: background-color on 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.

Share this post

Supercharge your web development workflow

Take your productivity to the next level, Today!

Written by
Author

Himanshu Mishra

Indie Maker and Founder @ UnveelWorks & Hoverify