Skip to main content
All posts

Tailwind CSS Dark Mode Toggle in React 19

7 June 2026

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:

  1. Browser downloads HTML
  2. Browser downloads CSS (Tailwind stylesheet)
  3. Browser paints initial HTML with CSS applied
  4. Browser downloads JavaScript
  5. 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:

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:

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:

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.

You might also like

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch