EmpireUI
Get Pro
← Blog7 min read#tailwind dark mode#css#dark theme

Tailwind Dark Mode: class Strategy vs system — What You're Missing

Tailwind's dark mode has two strategies and most devs pick the wrong one. Here's the real difference between class and system — and when each one breaks.

Dark mode developer interface showing code editor with dark theme

Two Strategies, One Config Line

In Tailwind v3 and beyond, you get exactly two dark mode strategies: class and system (previously called media). One line in your tailwind.config.js controls which one applies. That choice has bigger downstream effects than most tutorials bother to explain.

The system strategy uses the prefers-color-scheme media query — your UI just follows whatever the OS reports. Zero JavaScript, zero complexity. But you can't let users override it from inside your app, ever. That's the catch nobody mentions.

The class strategy instead looks for a .dark class on an ancestor element — usually <html> or <body>. You put that class there yourself, via JavaScript, and suddenly you own the whole toggle experience.

Which one you need depends entirely on whether you want user-controlled theming or hands-off OS-following. Honestly, most production apps need the class strategy even if they still want to default to OS preference — because users will click that toggle.

Setting Up class Strategy Correctly

Start with the config. One change:

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  theme: { extend: {} },
  plugins: [],
}

Now every dark: variant in your markup activates when .dark exists on a parent element. 0px of extra CSS specificity tricks required. Tailwind handles the selector cascade for you.

The simplest toggle you'll ever write looks like this — drop it in a custom hook and you're done:

// hooks/useDarkMode.ts
import { useEffect, useState } from 'react'

export function useDarkMode() {
  const [isDark, setIsDark] = useState(() => {
    if (typeof window === 'undefined') return false
    return localStorage.getItem('theme') === 'dark' ||
      (!localStorage.getItem('theme') &&
        window.matchMedia('(prefers-color-scheme: dark)').matches)
  })

  useEffect(() => {
    const root = document.documentElement
    if (isDark) {
      root.classList.add('dark')
      localStorage.setItem('theme', 'dark')
    } else {
      root.classList.remove('dark')
      localStorage.setItem('theme', 'light')
    }
  }, [isDark])

  return { isDark, toggle: () => setIsDark(d => !d) }
}

Worth noting: the localStorage check inside the initial state function means a user's manual preference survives refreshes. You still fall back to prefers-color-scheme on first visit. That's the pattern — class strategy doesn't mean you ignore the OS, it means you can override it.

Why system Strategy Quietly Breaks Things

The system strategy is genuinely fine for simple docs sites. But the moment you add any interactivity — a theme toggle button, a user settings panel, an admin preference — you're stuck.

There's no way to programmatically flip prefers-color-scheme. It's a read-only OS signal. You can read it with window.matchMedia, you can listen for changes, but you can't write to it. Period. Your React state doesn't matter. Your useState doesn't matter.

In practice, what I see devs do is start with system, then realise they need a toggle, then awkwardly try to wrap everything in a manual bg-white dark:bg-gray-900 while also managing a separate CSS variable override — it turns into spaghetti by week two.

Quick aside: the same limitation hits SSR. With the class strategy you can read a cookie or header on the server and inject the right class before the first paint, avoiding the dark-mode flash. With system only, your SSR HTML is always the same regardless of OS preference — you can't know it server-side without JavaScript running client-side first.

Avoiding the Flash of Unstyled Theme

This is the problem everyone hits eventually. You load the page, there's a 200–300ms white flash, then dark mode kicks in. Users hate it.

With the class strategy, the fix is to inline a small script in your <head> — before any CSS or React loads — that reads localStorage and sets the class synchronously:

<!-- In your <head>, before any stylesheets -->
<script>
  (function () {
    var stored = localStorage.getItem('theme')
    var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
    if (stored === 'dark' || (!stored && prefersDark)) {
      document.documentElement.classList.add('dark')
    }
  })()
</script>

That script is synchronous and blocking — intentionally. It runs before the browser paints anything, so by the time pixels hit the screen, .dark is already there. In Next.js you'd put this in a custom _document.tsx or use the <Script strategy="beforeInteractive"> component. In Vite/Remix, drop it directly into your HTML template.

The system media query approach never needs this trick since there's no stored preference to hydrate. But the moment you switch to class for proper toggle support, this script is non-negotiable.

Dark Mode in Component Design — The 24px Problem

Getting the strategy right is step one. Designing components that don't look terrible in dark mode is the real work. And there's a specific mistake that kills a lot of designs: using pure black (#000000 or bg-black) as a dark background.

Pure black next to any content creates a contrast so extreme that text gets hard to read for extended periods. Tailwind's own dark:bg-gray-900 (which maps to #111827) is already a better default. Most design systems in 2024–2025 landed on something in the #0f0f11 to #1a1a2e range for base backgrounds.

Look, if you're building dark-mode-first UI and want real inspiration, check out the glassmorphism components on Empire UI — those are designed specifically to look right on dark surfaces, with blur and translucency that only works when there's depth behind them. The glassmorphism generator lets you dial in the exact backdrop-filter and background opacity values before you commit to anything.

One more thing — shadows flip in dark mode too. A box-shadow that reads well on white backgrounds (shadow-md = 0 4px 6px rgba(0,0,0,0.1)) becomes invisible on dark surfaces since black shadows don't show up against dark backgrounds. You need colored or glowing shadows instead. The box shadow generator has a dark mode preview toggle for exactly this reason.

Selector Strategy in Tailwind v4

Tailwind v4 (released early 2025) changed how dark mode configuration works. Instead of tailwind.config.js, configuration moves to a CSS file, and the dark mode variant is declared differently:

/* app.css */
@import "tailwindcss";

@variant dark (&:is(.dark *));

That @variant dark line replaces the old darkMode: 'class' config key entirely. The selector &:is(.dark *) matches any element that's a descendant of .dark — same behavior, new syntax. If you're migrating a v3 project, this is the only dark mode change that'll bite you.

That said, v4 also introduced support for a selector variant strategy where you can use any arbitrary selector — not just .dark. Want to scope dark mode to a [data-theme="dark"] attribute instead of a class? You can. @variant dark (&:is([data-theme="dark"] *)) and you're done. Useful when you're integrating into a CMS or design system that already owns the theming attribute.

Putting It Together in a Real Component

Here's a nav bar component that uses class strategy dark mode, the useDarkMode hook from earlier, and a toggle button. Nothing fancy — but it shows how the pieces fit:

import { useDarkMode } from '@/hooks/useDarkMode'

export function Navbar() {
  const { isDark, toggle } = useDarkMode()

  return (
    <nav className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-4">
      <div className="flex items-center justify-between">
        <span className="text-gray-900 dark:text-white font-semibold text-lg">
          My App
        </span>
        <button
          onClick={toggle}
          className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
          aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
        >
          {isDark ? '☀️' : '🌙'}
        </button>
      </div>
    </nav>
  )
}

Every dark: class here only fires when .dark is on <html>. The transitions are 150ms by default from Tailwind's transition-colors utility. Add duration-300 if you want something smoother.

For more design patterns that work well in both modes, browsing the Empire UI component library is worth your time — especially if you're building something beyond the usual gray-scale dark theme and want glassmorphism, aurora, or cyberpunk aesthetics that are built for dark surfaces from the start.

FAQ

Should I use class or system for a new Tailwind project?

Use class almost always. It gives you everything system does (you can still read prefers-color-scheme and default to it), plus you get user-controlled toggling and SSR flash prevention. system is only sensible for static docs sites with zero interactivity.

Why does my dark mode flash white on page load?

You're missing a synchronous script in <head> that sets .dark before the first paint. Add an inline script that reads localStorage and applies the class — see the snippet above. This is specific to the class strategy.

Can I use both class and system strategies at once?

Not directly as a Tailwind config option — you pick one. But with class strategy you can listen to prefers-color-scheme changes via matchMedia in JavaScript and update your .dark class accordingly, giving you both behaviors.

What changed for dark mode in Tailwind v4?

Configuration moved from tailwind.config.js to a CSS @variant dark declaration. The behavior is the same, but the syntax is different — and you now have more flexibility to use custom selectors or data attributes instead of just .dark.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Dark Mode Component Variants in Tailwind: Every Pattern CoveredNeobrutalism with Tailwind: offset-y Shadows, Bold Borders, Raw TypographyWhat Is Glassmorphism? A Free React + Tailwind GuideCSS Box Shadow: The Complete Guide With Live Examples