| |

Tailwind CSS 38 🎨 Dark Mode Strategies (Media Queries vs Class/Selector-Based Dark Mode)

Dark mode is no longer a nice-to-have. Operating systems ship with it, browsers expose it, and users expect it. Tailwind’s dark: variant makes it straightforward to write styles that change when dark mode is active: bg-white dark:bg-gray-900 produces a white background in light mode and a dark gray one in dark mode. The syntax is consistent with every other Tailwind variant, and the utility set is the same. The question is not how to write dark mode styles, but how dark mode is activated.

Tailwind offers two activation strategies. The media strategy uses the prefers-color-scheme media query. It reads the user’s operating system preference and applies dark styles automatically. There is no toggle, no JavaScript, and no state to manage. The browser decides, and the page follows . The selector strategy uses a CSS selector — typically a .dark class on the <html> element — to activate dark mode. It requires JavaScript to toggle the class, but it gives the user explicit control. The two strategies are not competing defaults; they are different answers to the question of who decides the theme: the operating system or the user .

The choice matters because it determines the architecture of the theme system. A media-query site has no theme state. A selector-based site has a theme store, a toggle UI, persistence logic, and a flash-of-wrong-theme problem to solve. The media strategy is simpler to implement but less flexible. The selector strategy is more complex but more powerful. Neither is universally correct, and some applications need both.

This chapter covers three areas. First, why the two strategies exist — the fundamental difference between system preference and explicit user choice, and the cases where each is appropriate. Second, how the media strategy works — the default behavior, the prefers-color-scheme query, and what the browser does when the user changes their OS theme. Third, how the selector strategy works — overriding the dark variant in Tailwind v4, the .dark class and data-attribute approaches, the JavaScript for toggling, and the three-way theme system that supports light, dark, and system modes. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the activation flow for both strategies.

Key point: The media strategy uses prefers-color-scheme and responds to the operating system. The selector strategy uses a .dark class (or data attribute) on an ancestor element and responds to explicit user choice. The media strategy requires no JavaScript; the selector strategy requires JavaScript for toggling and a synchronous initialization script to avoid flash of wrong theme .


Why two dark mode strategies exist

The system-preference problem. Operating systems have a color scheme setting. When a user selects dark mode in macOS, Windows, or Android, every application that respects the setting switches to dark. The prefers-color-scheme media query exposes this preference to the browser. A website that uses the media strategy automatically matches the operating system. The user sets their preference once, at the OS level, and every site that respects it follows. There is no per-site toggle, no stored preference, and no inconsistency between applications. For users who set their OS theme and expect every application to follow, the media strategy is the correct behavior .

The explicit-choice problem. Not every user wants every site to follow their OS theme. A user might prefer dark mode for their operating system but light mode for reading documentation. Or dark mode for social media but light mode for a code editor. The OS preference is a global setting, but theme preference is contextual. The media strategy cannot express this nuance — it is all-or-nothing per site. The selector strategy gives the user explicit control. They can toggle the theme on a specific site, and the choice persists independently of the OS setting. This is why GitHub, YouTube, and most large applications offer a per-site theme toggle rather than relying solely on the OS preference .

The three-way problem. A good theme system does not force a binary choice between “always light” and “always dark.” It offers three modes: light, dark, and system. The system mode is the default: follow the OS preference. The light and dark modes are explicit overrides. The selector strategy enables this three-way system. The JavaScript checks for a stored preference. If none exists, or if the preference is “system,” it reads window.matchMedia('(prefers-color-scheme: dark)') and applies the corresponding class. If the user explicitly chooses light or dark, that choice is stored and applied regardless of the OS setting. The media strategy cannot express this — it only has two states, and both are controlled by the OS .

The flash-of-wrong-theme problem. The selector strategy introduces a timing problem. The .dark class must be present on the <html> element before the first paint. If the class is added by a React effect or a script that runs after the DOM is parsed, the user sees a flash of light theme before the dark theme applies. The fix is a synchronous script in the <head>, before any CSS is loaded. The script reads the stored preference (or the OS preference) and adds the .dark class immediately. This is a direct manipulation of the DOM, not a React state update, because React has not yet hydrated. The script must not be deferred or async .

The trade-off. The media strategy is zero-configuration. It works out of the box, requires no JavaScript, and cannot have a flash-of-wrong-theme problem because there is no state to initialize. The selector strategy requires JavaScript, a toggle UI, persistence logic, and a synchronous initialization script. It is more complex to implement and more complex to test. The trade-off is between simplicity and control. The media strategy is simpler; the selector strategy is more powerful. Applications that need per-site user control must use the selector strategy. Applications that are content to follow the OS preference can use the media strategy and avoid the complexity entirely .


a. The media strategy

The media strategy is Tailwind’s default. The dark: variant compiles to @media (prefers-color-scheme: dark). When the user’s operating system is set to dark mode, the browser applies the dark styles. When the OS is set to light mode, the dark styles are ignored .

<div class="bg-white dark:bg-gray-900">
  <h1 class="text-gray-900 dark:text-white">Title</h1>
</div>

The generated CSS is:

.bg-white {
  background-color: #fff;
}

@media (prefers-color-scheme: dark) {
  .dark\:bg-gray-900 {
    background-color: #111827;
  }
}

The media strategy has no state. There is no class to toggle, no preference to store, and no JavaScript to write. The browser reads the OS setting and applies the styles accordingly. If the user changes their OS theme while the page is open, the browser re-evaluates the media query and the styles update automatically. This is the simplest possible dark mode implementation .

The limitation is that the user cannot override the OS preference for a specific site. If the OS is set to light mode, the site is light mode. If the OS is set to dark mode, the site is dark mode. There is no toggle, and there is no per-site choice. For applications where the OS preference is the correct preference, this is a feature. For applications where the user expects a toggle, it is a limitation .

b. The selector strategy

The selector strategy overrides the dark variant to use a CSS selector instead of a media query. In Tailwind v4, this is done with the @custom-variant directive in the main CSS file. In v3, it was configured in tailwind.config.js with darkMode: 'selector' or darkMode: 'class' .

The class-based selector uses a .dark class on the <html> element:

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

With this configuration, dark: styles apply whenever the .dark class is present on an ancestor element. The :where() pseudo-class ensures the selector has the same specificity as the media strategy, preventing specificity conflicts .

The data-attribute selector uses an attribute instead of a class:

@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));

This applies dark styles when data-theme="dark" is present on an ancestor. Data attributes are useful when the theme state is already stored in an attribute, or when the class name dark conflicts with existing CSS .

The selector strategy requires JavaScript to toggle the theme. The toggle function adds or removes the .dark class on the <html> element and stores the preference in localStorage:

function toggleTheme() {
  const root = document.documentElement;
  const isDark = root.classList.contains("dark");
  root.classList.toggle("dark", !isDark);
  localStorage.setItem("theme", isDark ? "light" : "dark");
}

The stored preference is read on page load. The initialization script must run synchronously in the <head>, before any CSS is loaded, to avoid flash of wrong theme .

c. The three-way theme system

A three-way system supports light, dark, and system modes. The system mode follows the OS preference. The light and dark modes are explicit overrides. This is the pattern used by most production applications.

The initialization logic has three cases. First, if a preference is stored in localStorage, use it. Second, if no preference is stored, check the OS preference with window.matchMedia('(prefers-color-scheme: dark)'). Third, apply the corresponding class to the <html> element.

// On page load or when changing themes, best to add inline in <head> to avoid FOUC
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
  document.documentElement.classList.add('dark');
} else {
  document.documentElement.classList.remove('dark');
}

The toggle logic offers three choices: light, dark, and system. Choosing light stores 'light' in localStorage. Choosing dark stores 'dark'. Choosing system removes the stored preference, so the page falls back to the OS setting. The toggle UI typically uses radio buttons or a select element to represent the three states .


Complete Example Session

<!-- ============================================
     PART 1: THE MEDIA STRATEGY (DEFAULT)
     ============================================ -->

<!-- No configuration needed. The dark: variant
     uses prefers-color-scheme by default. -->

<div class="bg-white dark:bg-gray-900">
  <h1 class="text-gray-900 dark:text-white">Title</h1>
  <p class="text-gray-600 dark:text-gray-300">Body text</p>
</div>

<!-- The browser applies dark styles when the OS
     is set to dark mode. No JavaScript required. -->


<!-- ============================================
     PART 2: OVERRIDING THE DARK VARIANT
     ============================================ -->

<!-- In the main CSS file, override the dark variant
     to use a selector instead of a media query. -->

<style type="text/tailwindcss">
  @import "tailwindcss";

  /* Class-based selector */
  @custom-variant dark (&:where(.dark, .dark *));
</style>

<!-- Now dark: styles apply when .dark is present
     on an ancestor element, regardless of the OS. -->


<!-- ============================================
     PART 3: THE HTML STRUCTURE
     ============================================ -->

<!-- The .dark class goes on the <html> element. -->

<html class="dark">
  <body>
    <div class="bg-white dark:bg-gray-900">
      <!-- dark:bg-gray-900 applies because .dark is on <html> -->
    </div>
  </body>
</html>


<!-- ============================================
     PART 4: THE TOGGLE FUNCTION
     ============================================ -->

<!-- JavaScript toggles the .dark class and stores
     the preference. -->

<script>
  function toggleTheme() {
    const root = document.documentElement;
    const isDark = root.classList.contains("dark");

    if (isDark) {
      root.classList.remove("dark");
      localStorage.setItem("theme", "light");
    } else {
      root.classList.add("dark");
      localStorage.setItem("theme", "dark");
    }
  }
</script>

<button onclick="toggleTheme()">Toggle Theme</button>


<!-- ============================================
     PART 5: THE INITIALIZATION SCRIPT
     ============================================ -->

<!-- This script must run synchronously in <head>
     before any CSS loads to avoid flash of wrong theme. -->

<head>
  <script>
    if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
  </script>
</head>


<!-- ============================================
     PART 6: THE THREE-WAY SYSTEM
     ============================================ -->

<!-- Light, dark, and system modes. -->

<fieldset>
  <legend>Theme</legend>
  <input type="radio" name="theme" id="light" value="light">
  <label for="light">Light</label>

  <input type="radio" name="theme" id="dark" value="dark">
  <label for="dark">Dark</label>

  <input type="radio" name="theme" id="system" value="system">
  <label for="system">System</label>
</fieldset>


<!-- ============================================
     PART 7: THE THREE-WAY LOGIC
     ============================================ -->

<script>
  const THEME_KEY = 'theme';

  function applyTheme(theme) {
    const root = document.documentElement;
    if (theme === 'dark') {
      root.classList.add('dark');
    } else if (theme === 'light') {
      root.classList.remove('dark');
    } else {
      // System mode: follow OS preference
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      root.classList.toggle('dark', prefersDark);
    }
  }

  function setTheme(theme) {
    if (theme === 'system') {
      localStorage.removeItem(THEME_KEY);
    } else {
      localStorage.setItem(THEME_KEY, theme);
    }
    applyTheme(theme);
  }

  // On page load
  const saved = localStorage.getItem(THEME_KEY);
  if (saved) {
    applyTheme(saved);
  } else {
    applyTheme('system');
  }
</script>


<!-- ============================================
     PART 8: LISTENING TO SYSTEM CHANGES
     ============================================ -->

<!-- If the user is in system mode and changes
     their OS theme, update the page accordingly. -->

<script>
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

  mediaQuery.addEventListener('change', (e) => {
    // Only update if the user is in system mode
    if (!localStorage.getItem(THEME_KEY)) {
      document.documentElement.classList.toggle('dark', e.matches);
    }
  });
</script>


<!-- ============================================
     PART 9: THE DATA-ATTRIBUTE VARIANT
     ============================================ -->

<!-- Alternative: use data-theme instead of a class. -->

<style type="text/tailwindcss">
  @import "tailwindcss";
  @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
</style>

<html data-theme="dark">
  <!-- dark: styles apply -->
</html>


<!-- ============================================
     PART 10: THE FLASH-FREE INITIALIZATION
     ============================================ -->

<!-- The complete initialization script, placed
     in <head> before any CSS. -->

<head>
  <script>
    (function() {
      const saved = localStorage.getItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const isDark = saved === 'dark' || (!saved && prefersDark);
      document.documentElement.classList.toggle('dark', isDark);
    })();
  </script>
</head>

The ten parts show both strategies: the media strategy as the default, the selector strategy with class and data-attribute variants, the toggle function, the initialization script, the three-way system, system change listening, and the flash-free initialization.


Quick Reference

Strategy Comparison

AspectMedia StrategySelector Strategy
ActivationOS prefers-color-scheme.dark class or data-theme
ConfigurationNone (default)@custom-variant dark in v4
JavaScriptNot requiredRequired for toggling
User controlNone (follows OS)Full (per-site choice)
Three-wayNot possibleLight / Dark / System
Flash riskNoneRequires sync script in <head>
PersistenceOS settinglocalStorage

Tailwind v4 Configuration

GoalCSS
Media (default)No configuration
Class-based@custom-variant dark (&:where(.dark, .dark *));
Data-attribute@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));

The Three-Way Logic

User choicelocalStorageApplied class
Light'light'Remove .dark
Dark'dark'Add .dark
System(removed)Based on matchMedia

Flash Prevention

RequirementDetail
Script locationIn <head>, before CSS
Script timingSynchronous (not defer or async)
ActionRead preference, apply class immediately
ReasonPrevent flash of wrong theme before paint

Best Practices

✅ Do This:

/* Override dark variant with :where() for specificity */
@custom-variant dark (&:where(.dark, .dark *));                        /* ✅ */
<!-- Place initialization script in <head> before CSS -->
<script>/* read preference, apply class */</script>                   <!-- ✅ -->
// Support three modes: light, dark, system
if (theme === 'system') localStorage.removeItem('theme');              // ✅
// Listen to system changes when in system mode
mediaQuery.addEventListener('change', handler);                        // ✅
// Store explicit choices in localStorage
localStorage.setItem('theme', 'dark');                                 // ✅

❌ Don’t Do This:

<!-- Don't use defer or async on the init script -->
<script defer>/* ... */</script>                                       <!-- ❌ -->
// Don't toggle theme without persisting
root.classList.toggle('dark');                                         // ❌
// Don't ignore system changes in system mode
// (no matchMedia listener)                                            // ❌
// Don't use a class name that conflicts with utilities
@custom-variant dark (&:where(.dark-mode, .dark-mode *));              // ✅ (safe)

Common Pitfalls

PitfallWhy It HappensFix
Flash of wrong themeInit script runs after paintPlace script in <head>, synchronous
Toggle doesn’t persistNo localStorage writeStore preference on toggle
System mode doesn’t updateNo matchMedia listenerListen for change events
Specificity conflictsdark variant without :where()Use @custom-variant with :where()
Media strategy has no toggleDefault behaviorOverride with @custom-variant
Data attribute not appliedUsing class selector with attributeMatch variant selector to attribute
prefers-color-scheme ignoredUsing selector strategyMedia strategy is default; selector overrides
Script blocked by CSPInline script disallowedUse nonce or external file

Real-World Examples

1. Media Strategy (Default)

<div class="bg-white dark:bg-gray-900">

2. Class-Based Selector

@custom-variant dark (&:where(.dark, .dark *));

3. Data-Attribute Selector

@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));

4. Toggle Function

document.documentElement.classList.toggle('dark');

5. Store Preference

localStorage.setItem('theme', 'dark');

6. Flash-Free Init

<script>if (localStorage.theme === 'dark') document.documentElement.classList.add('dark');</script>

7. Three-Way System

if (theme === 'system') localStorage.removeItem('theme');

8. System Change Listener

mediaQuery.addEventListener('change', (e) => root.classList.toggle('dark', e.matches));

9. Specificity Control

@custom-variant dark (&:where(.dark, .dark *));

10. Prose Invert

<article class="prose dark:prose-invert">

Visual

The Media Strategy Flow

┌──────────────────────────────────────────────────────────────┐
│  MEDIA STRATEGY                                              │
│                                                              │
│  Operating System                                            │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  User sets OS to dark mode                           │    │
│  └──────────────────────────────────────────────────────┘    │
│         │                                                    │
│         ▼                                                    │
│  Browser                                                     │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  prefers-color-scheme: dark                          │    │
│  └──────────────────────────────────────────────────────┘    │
│         │                                                    │
│         ▼                                                    │
│  CSS                                                         │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  @media (prefers-color-scheme: dark) {               │    │
│  │    .dark\:bg-gray-900 { background: #111827; }       │    │
│  │  }                                                    │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  No JavaScript. No state. No toggle.                         │
│  The OS decides. The browser follows.                        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Selector Strategy Flow

┌──────────────────────────────────────────────────────────────┐
│  SELECTOR STRATEGY                                           │
│                                                              │
│  User clicks toggle                                          │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  toggleTheme()                                       │    │
│  │  → document.documentElement.classList.add('dark')    │    │
│  │  → localStorage.setItem('theme', 'dark')             │    │
│  └──────────────────────────────────────────────────────┘    │
│         │                                                    │
│         ▼                                                    │
│  <html class="dark">                                         │
│         │                                                    │
│         ▼                                                    │
│  CSS                                                         │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  @custom-variant dark (&:where(.dark, .dark *));     │    │
│  │  .dark\:bg-gray-900:where(.dark, .dark *) {          │    │
│  │    background: #111827;                              │    │
│  │  }                                                    │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  JavaScript controls the class. localStorage persists        │
│  the choice. The user decides.                               │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Three-Way System

┌──────────────────────────────────────────────────────────────┐
│  THREE-WAY THEME SYSTEM                                      │
│                                                              │
│  ┌─────────────┐                                             │
│  │  User Choice │                                             │
│  └─────────────┘                                             │
│         │                                                    │
│         ├── "light" ──► Remove .dark, store 'light'          │
│         │                                                    │
│         ├── "dark"  ──► Add .dark, store 'dark'              │
│         │                                                    │
│         └── "system" ──► Remove stored preference            │
│                          │                                   │
│                          ▼                                   │
│                    matchMedia('prefers-color-scheme: dark')  │
│                          │                                   │
│                          ├── true  ──► Add .dark             │
│                          │                                   │
│                          └── false ──► Remove .dark          │
│                                                              │
│  System mode follows the OS. Explicit modes override it.     │
│  The OS change listener keeps system mode in sync.           │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Flash of Wrong Theme Prevention

┌──────────────────────────────────────────────────────────────┐
│  FLASH PREVENTION                                            │
│                                                              │
│  WITHOUT sync script:                                        │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  1. HTML parsed (no .dark class)                     │    │
│  │  2. CSS loaded → light theme painted                 │    │
│  │  3. JavaScript runs → .dark added                    │    │
│  │  4. FLASH: user sees light, then dark                │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  WITH sync script in <head>:                                 │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  1. <head> script runs synchronously                 │    │
│  │  2. Reads localStorage / matchMedia                  │    │
│  │  3. Adds .dark to <html>                             │    │
│  │  4. CSS loaded → dark theme painted immediately      │    │
│  │  5. NO FLASH                                         │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  The script must be in <head>, before any CSS.               │
│  It must be synchronous (no defer, no async).                │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Media strategyUses prefers-color-scheme; default; no JS
Selector strategyUses .dark class or data attribute; requires JS
v4 configuration@custom-variant dark in main CSS
v3 configurationdarkMode: 'selector' in tailwind.config.js
Three-way modesLight, Dark, System
PersistencelocalStorage stores explicit choice
System syncmatchMedia listener for OS changes
Flash preventionSynchronous script in <head>
Specificity:where() ensures consistent specificity
Data attributedata-theme="dark" as alternative to class

Key takeaways:

  • The media strategy is the default and requires no configuration. The dark: variant compiles to @media (prefers-color-scheme: dark). The browser reads the OS preference and applies the styles. There is no JavaScript, no state, and no toggle.
  • The selector strategy gives users explicit control. Override the dark variant with @custom-variant dark (&:where(.dark, .dark *)) in v4. The .dark class on an ancestor activates dark mode. JavaScript toggles the class and persists the choice.
  • The three-way system is the production pattern. Light, dark, and system modes. System mode follows the OS. Explicit modes override it. The choice is stored in localStorage, and system changes are watched with matchMedia.
  • Flash of wrong theme is solved by a synchronous script in <head>. The script reads the stored preference (or the OS preference) and adds the .dark class before the first paint. It must not be deferred or async.
  • :where() controls specificity. The @custom-variant directive wraps the selector in :where(), so dark mode utilities have the same specificity as other utilities. Without it, dark mode styles could override styles they should not.
  • Data attributes are an alternative to classes. If the theme state is already stored in an attribute, or if the class name dark conflicts with existing CSS, use data-theme="dark" and the corresponding @custom-variant.
  • The media strategy cannot express per-site choice. It is binary: the OS decides. Applications that need a toggle must use the selector strategy and accept its JavaScript requirements.
  • Both strategies can coexist. A three-way system uses the selector strategy for explicit choices and falls back to the media query for system mode. The matchMedia API is the bridge between the two.

Remember: Dark mode activation is a choice between two strategies: follow the system or follow the user. The media strategy follows the system, uses prefers-color-scheme, and requires no JavaScript. The selector strategy follows the user, uses a .dark class or data attribute, and requires JavaScript for toggling and a synchronous script for flash prevention. A production theme system typically uses the selector strategy to support three modes — light, dark, and system — where system mode falls back to the OS preference via matchMedia. The selector strategy is more complex, but it gives users the control they expect. The media strategy is simpler, but it removes that control. The right choice depends on whether the application’s users need to override the system preference, and whether the simplicity of zero-configuration is worth the loss of per-site control.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!