Tailwind Dark Mode: class Strategy Done Right
Set up dark mode in Tailwind with the class strategy, persist the choice in localStorage, and avoid the flash of wrong theme on first paint.
What you'll learn
- ✓Why the class strategy beats media queries
- ✓How to wire up a toggle that persists
- ✓How to avoid the flash of incorrect theme
- ✓How to design color tokens that work in both modes
- ✓How to test dark mode in component libraries
Prerequisites
- •Familiar with Tailwind utilities — see What is Tailwind
Dark mode is no longer a nice-to-have. Users expect their site preference to be respected, their manual override to stick, and the page to render correctly on the very first paint. Tailwind makes the styling part trivial. The hard parts are wiring up the toggle, persisting the choice, and preventing the white flash before your script runs.
Two strategies, one winner
Tailwind supports two dark mode strategies:
media— uses theprefers-color-schemeCSS media query. Zero JavaScript. Cannot be overridden by the user.class(orselector) — toggles styles when a class likedarkis present on thehtmlelement. Lets you implement a manual toggle.
Real apps need a manual toggle, so class is the answer. Configure it once.
// tailwind.config.ts
import type { Config } from 'tailwindcss';
export default {
darkMode: 'class',
content: ['./src/**/*.{astro,html,js,ts,jsx,tsx}'],
} satisfies Config;
With this set, dark: variants apply when an ancestor has the dark class.
<div class="bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
Hello
</div>
The three states
A robust dark mode supports three user states, not two:
light— explicit choicedark— explicit choicesystem— follow the OS
Store the choice as one of those three strings. Compute the resolved theme at runtime.
type ThemeChoice = 'light' | 'dark' | 'system';
function resolvedTheme(choice: ThemeChoice): 'light' | 'dark' {
if (choice === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return choice;
}
The toggle script
Apply the resolved theme by adding or removing the dark class on document.documentElement.
export function applyTheme(choice: ThemeChoice) {
const resolved = resolvedTheme(choice);
document.documentElement.classList.toggle('dark', resolved === 'dark');
localStorage.setItem('theme', choice);
}
export function initToggle() {
const saved = (localStorage.getItem('theme') as ThemeChoice) ?? 'system';
applyTheme(saved);
// Re-apply when the OS theme changes and the user picked system.
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const current = (localStorage.getItem('theme') as ThemeChoice) ?? 'system';
if (current === 'system') applyTheme('system');
});
}
A toggle button just calls applyTheme('dark'), applyTheme('light'), or applyTheme('system').
Avoiding the flash
If the toggle script runs after the initial render, dark mode users see a white flash. The fix is an inline script in the document head, before any stylesheet or component renders. It runs synchronously and sets the class before paint.
<head>
<script>
(function () {
try {
var c = localStorage.getItem('theme') || 'system';
var dark = c === 'dark' || (c === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
} catch (e) {}
})();
</script>
</head>
This script must be inline. A separate file delays execution past the first paint, which is exactly the problem you are solving.
Designing tokens for both modes
The easiest mistake is to scatter dark: variants everywhere. The cleaner approach is to define a small set of semantic tokens via CSS variables and switch them once.
:root {
--bg: 255 255 255;
--fg: 15 23 42;
--muted: 100 116 139;
--border: 226 232 240;
}
.dark {
--bg: 15 23 42;
--fg: 241 245 249;
--muted: 148 163 184;
--border: 51 65 85;
}
Wire them to Tailwind by extending the theme:
theme: {
extend: {
colors: {
bg: 'rgb(var(--bg) / <alpha-value>)',
fg: 'rgb(var(--fg) / <alpha-value>)',
muted: 'rgb(var(--muted) / <alpha-value>)',
border: 'rgb(var(--border) / <alpha-value>)',
},
},
},
Now your components use bg-bg text-fg border-border and pick up the right colors automatically. Add dark: variants only when a component genuinely needs a different design in dark mode, not just a different shade.
Images and media
Photos often look harsh on dark backgrounds. Two small techniques help:
- Wrap raw images in a container with
dark:opacity-90to soften. - Use SVGs with
currentColorfor icons so they recolor with the text.
<svg class="text-fg" fill="currentColor" viewBox="0 0 24 24">...</svg>
Testing both modes
In Storybook, add a toolbar globalType that toggles the dark class on the preview body. In Playwright, run each test twice with a fixture that sets the theme. The goal is to catch contrast issues before they hit production.
Common pitfalls
- Forgetting to set
colorScheme: darkon form controls. Add<meta name="color-scheme" content="light dark">and Tailwind’saccent-utilities so native inputs (scrollbars, date pickers) match. - Using
transition-colorson the body. The very first theme change triggers a one-second fade across the entire page. Add the transition only on small components. - Storing the resolved theme instead of the choice. If you save
darkand the user later wants to follow system, you lose intent. Save the choice.
Wrap up
Tailwind’s class strategy gives you the styling primitives. The rest is plumbing: a tri-state choice, a tiny inline script that runs before paint, semantic CSS variables, and a habit of designing components in both modes from day one. Get those right and dark mode stops being a polish task and becomes the default way you build.