Tailwind CSS Dark Mode Toggle in React 19: Persisting User Preference Without Flash-of-Wrong-Theme on Hydration
Dark mode toggles sound trivial until you ship them to production and watch users squint as a white screen flashes across their face for 200ms before the correct theme loads. I’ve shipped this wrong three times—twice by overthinking it, once by underthinking it.
The problem isn’t React or Tailwind. It’s execution order. Your JavaScript runs after the browser paints the initial HTML. If that HTML defaults to light mode and your user prefers dark, they see light mode first, then dark mode pops in. Users hate this subtle flicker. It feels broken.
I’m sharing the production-ready solution from CitizenApp that eliminates this flicker entirely using a dead-simple trick: run a tiny script before React hydration even starts.
Why This Matters More Than You Think
When you build a React app with Tailwind, here’s what happens on first load:
- Browser downloads HTML
- Browser downloads CSS (Tailwind stylesheet)
- Browser paints initial HTML with CSS applied
- Browser downloads JavaScript
- React hydrates and your dark mode logic runs
If you put theme logic inside a React component or effect, you’re starting in step 5. Your user has already seen the wrong theme in step 3.
The flash-of-wrong-theme (FOUT) is a UX death by a thousand cuts. Most users won’t consciously notice it, but they’ll feel the site is “janky.” It’s the difference between a $50/month SaaS and a $500/month SaaS in perceived quality.
I prefer solving this with inline script injection because it’s:
- Predictable: Runs before React, guaranteed
- Lightweight: ~400 bytes of code
- Framework-agnostic: Works with any setup
- No hydration mismatches: The DOM already matches the correct theme
The Solution: Inline Script + localStorage + Tailwind Classes
Here’s the exact implementation from CitizenApp:
1. The Pre-Hydration Script
Drop this into your root HTML template (Astro, Next.js, etc.) in the <head> tag, before any CSS or React:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your App</title>
<!-- CRITICAL: Run before Tailwind CSS paints -->
<script>
(function() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = stored ? stored === 'dark' : prefersDark;
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script>
<!-- Tailwind CSS -->
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="root"></div>
<!-- React loads after body content -->
</body>
</html>
Why this works:
- IIFE execution: No dependencies, runs synchronously
- Placement: Before CSS means the
darkclass is set before Tailwind applies theme colors - localStorage check: Respects user’s previous choice
- System preference fallback: If no preference stored, uses
prefers-color-scheme
2. React Component for Toggle
Now your React component can safely assume the DOM is already correct:
'use client'; // or 'use strict' in Astro
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
const [mounted, setMounted] = useState(false);
// Hydration safety: only render after mount
useEffect(() => {
setMounted(true);
setIsDark(document.documentElement.classList.contains('dark'));
}, []);
if (!mounted) return null;
const toggle = () => {
const newIsDark = !isDark;
setIsDark(newIsDark);
// Update DOM
if (newIsDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Persist preference
localStorage.setItem('theme', newIsDark ? 'dark' : 'light');
};
return (
<button
onClick={toggle}
className="p-2 rounded-lg bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-200"
aria-label="Toggle theme"
>
{isDark ? '☀️' : '🌙'}
</button>
);
}
Key decisions:
mountedstate: Prevents hydration mismatch (React thinks DOM is dark, but client side hasn’t hydrated yet)- Direct DOM manipulation: We’re controlling a global state, so
classListis cleaner than context for this use case - Synchronous localStorage: Safe because it’s a toggle, not a render-blocking operation
3. Tailwind Configuration
Ensure Tailwind is configured to use the class strategy:
// tailwind.config.js
export default {
darkMode: 'class', // NOT 'media'
theme: {
extend: {},
},
plugins: [],
};
Why class and not media? Because media respects system preference only—you can’t override it with a toggle. class gives you full control.
Alternative: Context + Custom Hook (Overkill Most of the Time)
If you prefer React patterns, here’s a hook-based approach for CitizenApp’s settings page:
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext<{
isDark: boolean;
toggle: () => void;
}>({ isDark: false, toggle: () => {} });
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [isDark, setIsDark] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
setIsDark(document.documentElement.classList.contains('dark'));
}, []);
const toggle = () => {
const newIsDark = !isDark;
setIsDark(newIsDark);
document.documentElement.classList.toggle('dark', newIsDark);
localStorage.setItem('theme', newIsDark ? 'dark' : 'light');
};
if (!mounted) return children; // Avoid hydration mismatch
return (
<ThemeContext.Provider value={{ isDark, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
I prefer the simple button component in most cases. Context adds abstraction without benefit here.
Gotcha: System Preference Changes Mid-Session
This one burned me when a user toggled their OS dark mode and expected the app to follow. My inline script only runs once.
Fix: Listen for system preference changes:
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const stored = localStorage.getItem('theme');
if (!stored) { // Only auto-sync if user hasn't set preference
const newIsDark = e.matches;
setIsDark(newIsDark);
document.documentElement.classList.toggle('dark', newIsDark);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
What I Missed (And You Might Too)
The flash still happens if you don’t inline the script. Loading it as an external file defeats the purpose because the browser won’t execute it until after it downloads and parses. Keep it inline in the <head>.
Also, don’t overthink localStorage + context. For a toggle, DOM classes + localStorage are sufficient. I wasted a week on context architecture for what should’ve been 20 lines of code.
Ship It
That’s the entire pattern. No libraries, no magic, no flicker. On CitizenApp, we measure page paint timing and user feedback—this approach sits at 100ms paint time with zero dark mode complaints.
Test it: disable JavaScript, load the page with dark mode preferred. The theme should still be correct (because the inline script ran). Re-enable JavaScript and toggle. Works instantly.
This is a small detail, but small details are what separate production apps from hobby projects.