Getting class, media and @variant in v4 right
Dark mode in Tailwind CSS is more than a dark: prefix in front of every class. A well thought out dark mode strategy combines system synchronization, user preference persistence, CSS custom properties and a flicker-free toggle, from v3 all the way to the new @variant capabilities in v4.
Table of Contents
- 1. Dark mode in Tailwind CSS: the two base strategies
- 2. Class-based dark mode: implementation and toggle
- 3. prefers-color-scheme: system-based dark mode
- 4. Hybrid approach: combining system and user preference
- 5. Flicker-free dark mode without FOUC
- 6. @variant dark in Tailwind CSS v4
- 7. Dark mode with CSS custom properties and @theme
- 8. Dark mode strategies compared
- 9. Best practices and common mistakes
- 10. Summary
- 11. FAQ
1. Dark mode in Tailwind CSS: the two base strategies
Tailwind CSS dark mode can be implemented in two fundamental ways: through the CSS media feature prefers-color-scheme: dark, which reads the user's system setting, or through a CSS class on the html or body element that is set manually and controlled via JavaScript. Both strategies use the same Tailwind syntax in the HTML, the dark: prefix in front of utility classes. What differs is the mechanism that determines when these dark mode classes are active.
The system-based approach with prefers-color-scheme is the simpler of the two: you configure Tailwind accordingly, and the browser automatically activates every dark: class whenever the operating system is set to dark mode. It requires no JavaScript and is the most accessible approach. The downside is that the user has no control over the site-specific setting; anyone whose system is set to dark mode always gets dark mode, even if they would prefer light mode for this particular website. For most professional web applications, the hybrid approach that combines both strategies is the right choice.
The right Tailwind CSS dark mode strategy for a project depends on several factors: How important is user control? Is an SSR implementation required (where cookie-based dark mode is needed)? How large is the development team, and how well established are CSS custom properties in the codebase? The answers to these questions determine which of the strategies described below is the correct one.
2. Class-based dark mode: implementation and toggle
Class-based Tailwind CSS dark mode is activated by a CSS class on the html element. In Tailwind CSS v3 you configure this via darkMode: 'class' in tailwind.config.js. That makes Tailwind activate every class carrying the dark: prefix as soon as the html element carries the class dark. This approach gives JavaScript full control over when dark mode is active, letting you react to user preferences, stored settings and system events.
A complete dark mode toggle in vanilla JavaScript for class-based Tailwind CSS dark mode consists of three parts: the initial loading of the stored preference (from localStorage), the toggle button handler that sets and removes the dark class, and the system event listener that reacts to changes in prefers-color-scheme whenever no explicit user preference has been stored. Together, these three parts form a robust dark mode implementation that correctly handles both stored preferences and system changes.
// dark-mode.js, complete Tailwind CSS Dark Mode implementation
// Supports: localStorage persistence, system sync, no-flicker
(function () {
'use strict';
const STORAGE_KEY = 'color-scheme';
const html = document.documentElement;
// --- Initial setup (runs before paint to avoid flicker) ---
function getStoredPreference() {
try { return localStorage.getItem(STORAGE_KEY); } catch { return null; }
}
function getSystemPreference() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyColorScheme(scheme) {
if (scheme === 'dark') {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
html.style.colorScheme = scheme; // Tells browser to style native UI dark
}
// Apply preference immediately (before DOM paint = no FOUC)
const stored = getStoredPreference();
applyColorScheme(stored ?? getSystemPreference());
// --- Toggle function (called by the UI button) ---
window.toggleDarkMode = function () {
const isDark = html.classList.contains('dark');
const next = isDark ? 'light' : 'dark';
applyColorScheme(next);
try { localStorage.setItem(STORAGE_KEY, next); } catch {}
};
// --- Sync with system changes (when no stored preference) ---
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!getStoredPreference()) {
applyColorScheme(e.matches ? 'dark' : 'light');
}
});
})();
The script must be loaded synchronously in the <head>, not with defer or async. This is the decisive point for a flicker-free Tailwind dark mode: the script runs before the browser renders the first frame and sets the dark class ahead of the paint. If the script were loaded asynchronously, the page would briefly appear in the wrong mode before JavaScript applies the correct setting, the so-called flash of unstyled content (FOUC) for dark mode.
3. prefers-color-scheme: system-based dark mode
System-based Tailwind CSS dark mode via prefers-color-scheme is the default mode in Tailwind CSS v4. The dark: variant reacts to the system setting with no further configuration. In v3, this mode required darkMode: 'media' in tailwind.config.js (or was the default when no darkMode was configured). In v4, prefers-color-scheme is automatically the default because no explicit dark mode configuration is needed anymore.
The purely system-based approach is ideal for projects whose target audience is technically savvy and expects websites to respect their system settings. Developer tools, API documentation and technical blogs frequently fall into this category. For e-commerce applications, marketing pages or web applications with a broad audience, the hybrid approach with a toggle is the better choice, because not every user knows how to check or operate their system's dark mode setting.
4. Hybrid approach: combining system and user preference
The hybrid approach combines the best of both Tailwind CSS dark mode strategies: it starts with the system setting as the default value and lets the user override and persist that setting for the site. The result is a dark mode implementation that starts correctly for every user, those with system dark mode get dark mode, those with system light mode get light mode, while still offering user control.
Technically, the hybrid approach distinguishes between three states: auto (follows the system setting), dark (explicit dark mode regardless of the system), and light (explicit light mode regardless of the system). In localStorage you store dark or light for explicit preferences, and nothing (or auto) when the user wants to use the system setting. A triple toggle, or a select with three options, gives the user full control. This approach mirrors the behavior of modern operating systems and applications such as VS Code or GitHub.
5. Flicker-free dark mode without FOUC
The flash of unstyled content (FOUC) in Tailwind CSS dark mode occurs when the dark mode script runs after the browser's first render cycle. The browser briefly shows the light mode version before JavaScript sets the dark class and the browser re-renders. This flicker is especially disruptive for users with system dark mode or a stored dark mode preference, who expect the page to appear in the correct mode immediately.
The solution for flicker-free Tailwind dark mode is a minimal, synchronous inline script in the <head> of the HTML page. This script reads the stored preference and sets the dark class before the first paint. It should be as small as possible, ideally under 200 bytes, because synchronous scripts block the HTML parser. The actual dark mode management script can then be loaded asynchronously or with defer. Important: the inline script only sets the dark class and the color-scheme CSS property; all further functionality lives in the deferred script.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- Anti-FOUC script: runs synchronously before first paint -->
<!-- Keep this script minimal, it blocks HTML parsing -->
<script>
(function(){
var s = localStorage.getItem('color-scheme');
var p = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (s === 'dark' || (!s && p)) {
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
}
})();
</script>
<!-- Full dark mode logic deferred, does not block parsing -->
<script defer src="/js/dark-mode.js"></script>
<!-- Tailwind CSS with dark: variant ready from first paint -->
<link rel="stylesheet" href="/css/app.css">
</head>
<body class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">
<!-- Dark mode toggle button -->
<button
onclick="toggleDarkMode()"
class="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700"
aria-label="Toggle dark mode">
<!-- Sun icon (visible in dark mode) -->
<svg class="w-5 h-5 hidden dark:block" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm..."/>
</svg>
<!-- Moon icon (visible in light mode) -->
<svg class="w-5 h-5 block dark:hidden" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
</button>
</body>
</html>
6. @variant dark in Tailwind CSS v4
In Tailwind CSS v4, dark mode configuration moves from tailwind.config.js into CSS. The darkMode key from v3 disappears completely. Instead, the dark variant is defined either implicitly (the default reacts to prefers-color-scheme) or explicitly via an @variant declaration in the CSS entry file. For class-based Tailwind CSS dark mode in v4 you write: @variant dark (&:where(.dark, .dark *));
This @variant declaration overrides the default media query behavior and instead binds the dark: variant to the selector :where(.dark, .dark *). That means every dark: class becomes active when the element itself, or one of its ancestors, sits inside an element carrying the class dark. The :where() selector has no specificity and thereby prevents unexpected specificity conflicts. For projects that use server-side rendering and control dark mode via a cookie, the variant can be adapted accordingly: @variant dark (&:where([data-theme=dark], [data-theme=dark] *));
7. Dark mode with CSS custom properties and @theme
The most advanced Tailwind CSS dark mode strategy combines the dark: variant with a semantic design token system built on CSS custom properties. Instead of defining explicit dark: classes on every element, you define semantic tokens, such as --color-background, --color-foreground, --color-surface, and override those tokens in dark mode. That means an element with bg-background needs no dark:bg-slate-900 class, because the --color-background token switches to the dark value automatically in dark mode.
In Tailwind CSS v4 with @theme, this pattern can be implemented especially elegantly. You define the light mode tokens inside @theme, and the dark mode overrides inside an @variant dark block or a @media (prefers-color-scheme: dark) block. The benefit is that the HTML becomes considerably cleaner: instead of class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700", class="bg-background text-foreground border-default" is enough. The dark mode behavior is fully encapsulated in the CSS tokens.
/* Tailwind CSS v4: Dark Mode with semantic @theme tokens */
@import "tailwindcss";
/* Class-based dark mode variant */
@variant dark (&:where(.dark, .dark *));
@theme {
/* Light mode tokens (defaults) */
--color-background: #ffffff;
--color-surface: #f8fafc;
--color-surface-alt: #f1f5f9;
--color-foreground: #0f172a;
--color-foreground-muted: #64748b;
--color-border: #e2e8f0;
--color-interactive: #2563eb;
}
/* Dark mode token overrides */
@variant dark {
@theme {
--color-background: #0f172a;
--color-surface: #1e293b;
--color-surface-alt: #334155;
--color-foreground: #f8fafc;
--color-foreground-muted: #94a3b8;
--color-border: #334155;
--color-interactive: #60a5fa;
}
}
/* Base styles using semantic tokens, automatically dark-mode-aware */
@layer base {
body {
background-color: var(--color-background);
color: var(--color-foreground);
}
hr, [class*="border"] {
border-color: var(--color-border);
}
}
/* Result: no dark: prefixes needed in HTML for most elements */
/* <div class="bg-background text-foreground border-border"> works in both modes */
8. Dark mode strategies compared
A direct comparison of the various Tailwind CSS dark mode strategies shows which approach best fits which project requirements.
| Strategy | User control | SSR-compatible | HTML overhead | Best for |
|---|---|---|---|---|
| prefers-color-scheme | System only | Yes | Medium | Dev tools, blogs |
| Class-based (toggle) | Full | With cookie | Medium | Apps, e-commerce |
| Hybrid (system + toggle) | System + override | With cookie | Medium | All public websites |
| Semantic tokens + @theme | Full | Yes | Low | Design systems, large teams |
For most modern web projects, the combination of class-based Tailwind CSS dark mode with semantic tokens is the optimal strategy. It offers user control, clean HTML and a maintainable CSS architecture. The FOUC problem is solved through the synchronous inline script, and SSR compatibility through cookies for the initial server render. In Tailwind CSS v4, this strategy is particularly elegant to implement with @variant dark and token overrides in @theme.
9. Best practices and common mistakes
The most common mistake in Tailwind CSS dark mode implementations is forgetting the color-scheme CSS property. This property tells the browser that the page has a dark mode, and causes native browser UI elements, scrollbars, form controls, file pickers, to appear in dark mode as well. Without html { color-scheme: dark; } you end up with dark backgrounds from Tailwind classes but light scrollbars and light form elements, an inconsistent result. The color-scheme property is set programmatically in the dark mode script: document.documentElement.style.colorScheme = 'dark'.
A second common mistake concerns image handling in Tailwind CSS dark mode. Bright images on a dark background often look too harsh. The CSS property img { filter: brightness(0.9) contrast(1.05); } under a dark mode selector gives images a softer appearance in dark mode. As a Tailwind CSS custom utility, this can be solved elegantly: @utility dark-img-adjust with the corresponding filter values, applied via the dark: variant. For logos and icons, SVGs using currentColor are recommended, since they switch automatically together with the text color.
10. Summary
A well thought out Tailwind CSS dark mode strategy is more than adding dark: prefixes to utility classes. It starts with the right architectural decision, system-based, class-based or hybrid, and includes a semantic token system for maintainable HTML, a flicker-free initialization script for the best user experience, and correct handling of native browser elements via color-scheme.
Tailwind CSS v4 significantly simplifies dark mode configuration: the @variant dark directive replaces darkMode: 'class' from tailwind.config.js, and token overrides in @theme inside @variant dark blocks make semantic dark mode systems possible without duplicate classes in the HTML. The result is a dark mode implementation that is maintainable, accessible and works correctly for every user, regardless of their system settings.
Tailwind CSS Dark Mode Strategy: the essentials at a glance
No FOUC
Minimal inline script in the head, run synchronously before the browser renders. Set the dark class before the first paint.
@variant dark in v4
@variant dark (&:where(.dark, .dark *)) fully replaces darkMode: 'class' in tailwind.config.js.
Semantic tokens
Override @theme tokens inside the @variant dark block, clean HTML without duplicate dark:-classes on every element.
color-scheme property
html { color-scheme: dark } for native browser elements (scrollbars, forms). Without this property native UI elements are always light.