Tailwind Dark Mode: class vs media, system preference, manual toggle
Tailwind dark mode done right: class vs media strategy, system preference detection, manual toggles, and localStorage persistence. Real code, no fluff.
Tailwind Dark Mode: Two Strategies, One Real Choice
Honestly, most developers pick the wrong dark mode strategy and then spend a weekend wondering why their toggle doesn't work in production. Let's fix that upfront.
Tailwind gives you two modes: media and class. The media strategy ties dark mode to the OS-level prefers-color-scheme media query — you get zero JavaScript, zero state, and zero control. The class strategy toggles a dark class on the root <html> element, giving you full programmatic control over when dark mode fires.
In Tailwind v4.0.2, the config syntax changed slightly. You now declare your dark mode variant in your CSS entry file rather than tailwind.config.js. That's a meaningful shift if you're migrating from v3. The @variant dark approach pairs with @custom-variant to give you full control in a single stylesheet.
Which one should you pick? If your users need a manual toggle — and in 2026, they almost always do — you want class. Full stop.
Configuring class-based Dark Mode in Tailwind v4
In Tailwind v4, you wire up class-based dark mode directly in your CSS. No more darkMode: 'class' in tailwind.config.js. Here's what the setup looks like:
/* globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));That single line tells Tailwind's v4 compiler to generate dark: utilities that activate whenever a .dark class exists on an ancestor element. You can scope it to html.dark or body.dark depending on your preference — scoping to html is the most common pattern and avoids hydration edge cases in Next.js.
Once that's in place, you can write dark:bg-zinc-900 dark:text-zinc-100 anywhere in your components and they'll respond to the class toggle. No extra configuration needed. If you're still on v3 and want to understand the broader config differences, the Tailwind v4 features breakdown covers what changed and why.
Reading System Preference with matchMedia
Even with class-based dark mode, you'll want to respect the user's system preference on first load. That means reading prefers-color-scheme before React hydrates — otherwise you get a flash of the wrong theme.
The trick is a tiny inline script that runs synchronously in <head>. It reads localStorage first, then falls back to window.matchMedia.
// Paste this as an inline <script> in your <head> — before any CSS loads
(function () {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (!stored && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();This runs before React touches the DOM. No flicker, no hydration mismatch. The localStorage key 'theme' stores either 'dark' or 'light'. If nothing's stored, the system preference wins. That's the right priority order: explicit user choice first, then OS default.
Building the Manual Toggle in React
With the inline script handling initial load, your React toggle just needs to sync the class and localStorage going forward. Here's a minimal hook:
import { useEffect, useState } from 'react';
export function useDarkMode() {
const [isDark, setIsDark] = useState(() => {
if (typeof window === 'undefined') return false;
return document.documentElement.classList.contains('dark');
});
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(prev => !prev) };
}The initializer reads directly from the DOM class rather than localStorage — that way it stays in sync with whatever the inline script set. No double-reads, no stale state on first render.
If you want a full button component with animation, check out the theme toggle in React guide — it goes deeper on accessible toggle button patterns with ARIA attributes and keyboard support.
CSS Variables Strategy: One Source of Truth
Class toggling works great, but the real scalability comes from combining it with CSS custom properties. Instead of sprinkling dark: utilities on every element, you define your palette as variables on :root and override them inside .dark.
:root {
--color-bg: #ffffff;
--color-surface: rgba(255, 255, 255, 0.15);
--color-text-primary: #18181b;
--color-border: #e4e4e7;
}
.dark {
--color-bg: #09090b;
--color-surface: rgba(255, 255, 255, 0.06);
--color-text-primary: #fafafa;
--color-border: #27272a;
}Then in Tailwind v4, you wire these into your theme using @theme inline. Your components reference bg-[var(--color-bg)] or you map them to Tailwind tokens directly. This approach dramatically reduces the number of dark: variants you write per component — a single variable flip handles everything.
This pattern pairs naturally with glassmorphism surfaces, where rgba(255,255,255,0.15) in light mode becomes rgba(255,255,255,0.06) in dark. For a deeper look at that approach, the tailwind glassmorphism advanced guide shows how to manage backdrop-filter and surface opacity across both themes without duplication.
Listening for System Preference Changes at Runtime
Here's something a lot of tutorials skip: users can switch their OS theme while your app is open. If you're in class mode and only read matchMedia on page load, your app goes stale.
The fix is a change event listener on the media query. You only want this active when the user hasn't made an explicit choice — if they've stored a preference in localStorage, respect that instead.
useEffect(() => {
const stored = localStorage.getItem('theme');
if (stored) return; // user has a preference, don't override it
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
document.documentElement.classList.toggle('dark', e.matches);
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);Wire this into your app's root component or a context provider. Now your app stays in sync with the OS in real time — but the moment a user explicitly picks a theme, their choice sticks until they clear it.
Dark Mode with Tailwind Component Patterns
At scale, inconsistent dark mode handling becomes a real maintenance headache. You end up with some components using dark: utilities directly, others relying on CSS variables, and a few forgotten ones that don't respond at all. Sound familiar?
The pattern that holds up best is a strict separation: CSS variables own the semantic color palette, Tailwind utilities handle layout and spacing, and dark: variants are reserved only for structural changes that can't be expressed as a color swap — things like dark:border-opacity-20 or dark:shadow-none.
If you're building a component library on top of this, the Tailwind component patterns article covers how to structure variant props and slot-based theming so your dark mode logic doesn't leak into every consumer component. It's a worthwhile read before you start abstracting.
Also worth noting: OKLCH colors in Tailwind v4 make dark mode palette management significantly cleaner. Because OKLCH is perceptually uniform, you can predictably lighten or darken colors without them going muddy. The Tailwind OKLCH colors guide explains the lightness axis tricks that make this work.
Common Pitfalls and How to Sidestep Them
The flash-of-wrong-theme bug is the most common one, and we already covered the inline script fix. But there are a few others worth flagging.
SSR hydration mismatches happen when your server renders with one theme and the client initializes with another. The inline script approach prevents this because the class is set before React hydrates. If you're using Next.js App Router, put the inline script in your root layout.tsx inside a <Script strategy='beforeInteractive'> tag — or just a raw <script dangerouslySetInnerHTML> in the <head>.
Another subtle one: if you use Tailwind's @apply inside component stylesheets and include dark: variants, those generated selectors depend on the class being present at the right level in the DOM. Test with your actual DOM structure, not just a simplified sandbox. A dark: utility applied to a deeply nested element won't work if the .dark class is scoped too narrowly.
Finally, don't forget about third-party embeds — maps, charts, iframes. Those don't respond to your class toggle. You'll need to use their APIs or CSS filter hacks (filter: invert(0.9) hue-rotate(180deg) on the container) as a fallback. It's not pretty but it works.
FAQ
'media' activates dark styles based on the OS prefers-color-scheme setting automatically, with no JavaScript required. 'class' requires a .dark class on the html element, giving you programmatic control via a toggle. In Tailwind v4, you configure this with @custom-variant in CSS instead of tailwind.config.js.
Add an inline synchronous script to your <head> that reads localStorage and conditionally adds the 'dark' class to document.documentElement before React hydrates. In Next.js App Router, use <script dangerouslySetInnerHTML> or next/script with strategy='beforeInteractive' in your root layout.tsx.
CSS variables are more maintainable at scale. Define your palette on :root and override inside .dark — then components automatically switch without needing dark: on every element. Reserve dark: utilities for structural changes like shadow adjustments or border opacity that can't be expressed as a color variable swap.
Use window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handler). Only activate this listener when the user hasn't stored an explicit theme preference in localStorage — otherwise their manual choice gets overridden by the OS change.
Add @custom-variant dark (&:where(.dark, .dark *)); to your CSS entry file (e.g. globals.css) after @import 'tailwindcss'. This replaces the darkMode: 'class' option from tailwind.config.js in v3. All dark: utilities will then activate when any ancestor has the .dark class.
Yes. The @custom-variant dark (&:where(.dark, .dark *)) selector matches any element with .dark as an ancestor, not just html. You can add .dark to a specific container div and all dark: utilities inside that subtree will activate without affecting the rest of the page.