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
| Aspect | Media Strategy | Selector Strategy |
|---|---|---|
| Activation | OS prefers-color-scheme | .dark class or data-theme |
| Configuration | None (default) | @custom-variant dark in v4 |
| JavaScript | Not required | Required for toggling |
| User control | None (follows OS) | Full (per-site choice) |
| Three-way | Not possible | Light / Dark / System |
| Flash risk | None | Requires sync script in <head> |
| Persistence | OS setting | localStorage |
Tailwind v4 Configuration
| Goal | CSS |
|---|---|
| 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 choice | localStorage | Applied class |
|---|---|---|
| Light | 'light' | Remove .dark |
| Dark | 'dark' | Add .dark |
| System | (removed) | Based on matchMedia |
Flash Prevention
| Requirement | Detail |
|---|---|
| Script location | In <head>, before CSS |
| Script timing | Synchronous (not defer or async) |
| Action | Read preference, apply class immediately |
| Reason | Prevent 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Flash of wrong theme | Init script runs after paint | Place script in <head>, synchronous |
| Toggle doesn’t persist | No localStorage write | Store preference on toggle |
| System mode doesn’t update | No matchMedia listener | Listen for change events |
| Specificity conflicts | dark variant without :where() | Use @custom-variant with :where() |
| Media strategy has no toggle | Default behavior | Override with @custom-variant |
| Data attribute not applied | Using class selector with attribute | Match variant selector to attribute |
prefers-color-scheme ignored | Using selector strategy | Media strategy is default; selector overrides |
| Script blocked by CSP | Inline script disallowed | Use 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
| Item | Value |
|---|---|
| Media strategy | Uses prefers-color-scheme; default; no JS |
| Selector strategy | Uses .dark class or data attribute; requires JS |
| v4 configuration | @custom-variant dark in main CSS |
| v3 configuration | darkMode: 'selector' in tailwind.config.js |
| Three-way modes | Light, Dark, System |
| Persistence | localStorage stores explicit choice |
| System sync | matchMedia listener for OS changes |
| Flash prevention | Synchronous script in <head> |
| Specificity | :where() ensures consistent specificity |
| Data attribute | data-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
darkvariant with@custom-variant dark (&:where(.dark, .dark *))in v4. The.darkclass 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 withmatchMedia. - 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.darkclass before the first paint. It must not be deferred or async. :where()controls specificity. The@custom-variantdirective 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
darkconflicts with existing CSS, usedata-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
matchMediaAPI 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!