How to Add Dark Mode to a React App with Tailwind CSS

How to Add Dark Mode to a React App with Tailwind CSS

by | May 7, 2026 | Uncategorized | 0 comments

Why Dark Mode Matters in 2026

Dark mode is no longer a nice-to-have feature. Users expect it. It reduces eye strain, saves battery on OLED screens, and simply looks great. If you are building a React application with Tailwind CSS, you are in luck: Tailwind ships with first-class dark mode support that makes implementation straightforward.

In this tutorial, you will learn exactly how to add dark mode to a React app with Tailwind CSS. We will build a clean toggle button, persist the user’s choice in localStorage, and respect system-level preferences using window.matchMedia(). The result is a polished, three-way dark mode system that works in production.

This guide covers both Tailwind CSS v4 and Tailwind CSS v3, so you can follow along regardless of which version your project uses.

Prerequisites

  • A working React project (Create React App, Vite, or Next.js)
  • Tailwind CSS installed and configured
  • Basic knowledge of React hooks (useState, useEffect, useContext)

If you are starting a fresh project in 2026, we recommend using Vite + React + Tailwind CSS v4 for the fastest setup.

How Tailwind CSS Dark Mode Works

Before we write any code, let’s understand the two dark mode strategies Tailwind offers:

Strategy How It Works Best For
media (default) Uses the prefers-color-scheme CSS media query. Dark mode activates automatically based on the user’s OS setting. Apps that only need to follow system preferences
selector (formerly “class”) Dark mode activates when a dark class (or custom selector) is present on a parent element, typically <html>. Apps that need a manual toggle with user control

Since we want a toggle-based dark mode that users can control, we will use the selector strategy. This gives us full control while still allowing us to respect system preferences as a default.

Step 1: Configure Tailwind CSS for Dark Mode

The configuration differs slightly between Tailwind v3 and v4. Follow the instructions for your version.

Tailwind CSS v3 Configuration

Open your tailwind.config.js file and set the darkMode option to 'class':

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  content: [
    './src/**/*.{js,jsx,ts,tsx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

This tells Tailwind to look for a dark class on a parent element instead of relying on the system media query.

Tailwind CSS v4 Configuration

In Tailwind v4, the configuration has moved to your CSS file. You use the @custom-variant directive if you want a custom selector, but the default dark: variant already works with the .dark class on the <html> element when you set the source strategy to selector. Add this to your main CSS file:

/* app.css or globals.css */
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

This ensures the dark: variant activates when the .dark class is present on any ancestor element.

Step 2: Create a Theme Context

We want the dark mode state to be accessible throughout the entire app. The cleanest way to achieve this in React is with a Context + Provider pattern.

Create a new file called ThemeContext.jsx (or .tsx if you use TypeScript):

// src/context/ThemeContext.jsx
import { createContext, useContext, useEffect, useState } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(() => {
    // Check localStorage first
    if (typeof window !== 'undefined') {
      const stored = localStorage.getItem('theme');
      if (stored === 'dark' || stored === 'light') {
        return stored;
      }
      // Fall back to system preference
      if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        return 'dark';
      }
    }
    return 'light';
  });

  useEffect(() => {
    const root = document.documentElement;
    if (theme === 'dark') {
      root.classList.add('dark');
    } else {
      root.classList.remove('dark');
    }
    localStorage.setItem('theme', theme);
  }, [theme]);

  // Optional: listen for system preference changes
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    const handleChange = (e) => {
      const stored = localStorage.getItem('theme');
      if (!stored) {
        setTheme(e.matches ? 'dark' : 'light');
      }
    };
    mediaQuery.addEventListener('change', handleChange);
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, []);

  const toggleTheme = () => {
    setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
  };

  return (
    <ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}

What This Code Does

  1. Checks localStorage for a previously saved preference.
  2. Falls back to the system preference using window.matchMedia('(prefers-color-scheme: dark)').
  3. Adds or removes the dark class on the <html> element whenever the theme changes.
  4. Persists the choice in localStorage so it survives page refreshes.
  5. Listens for system-level changes in real time (e.g., the user switches their OS to dark mode).

Step 3: Wrap Your App with the ThemeProvider

Open your main entry file (usually main.jsx or App.jsx) and wrap everything with the ThemeProvider:

// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { ThemeProvider } from './context/ThemeContext';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <ThemeProvider>
      <App />
    </ThemeProvider>
  </React.StrictMode>
);

Step 4: Build a Dark Mode Toggle Button

Now let’s create a reusable toggle component. This button will switch between light and dark themes and display an appropriate icon.

// src/components/ThemeToggle.jsx
import { useTheme } from '../context/ThemeContext';

export default function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button
      onClick={toggleTheme}
      className="p-2 rounded-lg bg-gray-200 dark:bg-gray-700
                 text-gray-800 dark:text-gray-200
                 hover:bg-gray-300 dark:hover:bg-gray-600
                 transition-colors duration-200"
      aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
    >
      {theme === 'dark' ? (
        <span role="img" aria-label="sun">☀️</span>
      ) : (
        <span role="img" aria-label="moon">🌙</span>
      )}
    </button>
  );
}

Drop this component into your navbar or header, and you have a working toggle.

Step 5: Style Your Components with Tailwind’s dark: Variant

With the setup complete, you can now use the dark: prefix on any Tailwind utility class. Here is an example of a simple card component:

// src/components/Card.jsx
export default function Card({ title, description }) {
  return (
    <div className="bg-white dark:bg-gray-800
                    text-gray-900 dark:text-gray-100
                    border border-gray-200 dark:border-gray-700
                    rounded-xl shadow-md p-6
                    transition-colors duration-200">
      <h3 className="text-xl font-bold mb-2">{title}</h3>
      <p className="text-gray-600 dark:text-gray-400">{description}</p>
    </div>
  );
}

The pattern is simple: define the light mode style first, then add the dark: variant with the dark mode style. Tailwind handles the rest.

Common Dark Mode Class Pairs

Here is a quick reference table of commonly used class pairs:

Element Light Mode Dark Mode
Page background bg-white dark:bg-gray-900
Card background bg-gray-50 dark:bg-gray-800
Primary text text-gray-900 dark:text-gray-100
Secondary text text-gray-600 dark:text-gray-400
Borders border-gray-200 dark:border-gray-700
Input fields bg-white dark:bg-gray-700

Step 6: Prevent the Flash of Wrong Theme (FOWT)

One common issue with client-side dark mode is a brief flash of the wrong theme on page load. The browser renders the HTML before React hydrates the state. To fix this, add a small inline script in your index.html before any other scripts:

<!-- index.html, inside <head> or at the top of <body> -->
<script>
  (function() {
    const theme = localStorage.getItem('theme');
    if (
      theme === 'dark' ||
      (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)
    ) {
      document.documentElement.classList.add('dark');
    }
  })();
</script>

This runs synchronously before the page renders, so the correct theme is applied immediately. No flash.

Step 7 (Optional): Build a Three-Way Toggle

Some applications offer three options: Light, Dark, and System. This gives users the ability to explicitly defer to their OS setting. Here is how to extend the context to support this:

// Updated state logic inside ThemeProvider
const [preference, setPreference] = useState(() => {
  if (typeof window !== 'undefined') {
    return localStorage.getItem('theme-preference') || 'system';
  }
  return 'system';
});

const resolvedTheme = (() => {
  if (preference === 'system') {
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light';
  }
  return preference;
})();

useEffect(() => {
  const root = document.documentElement;
  root.classList.toggle('dark', resolvedTheme === 'dark');
  localStorage.setItem('theme-preference', preference);
}, [preference, resolvedTheme]);

Then in your UI, instead of a simple toggle button, you can render three buttons or a dropdown with the options “Light”, “Dark”, and “System”.

Complete Project Structure

Here is what your file structure looks like after following all the steps:

src/
  context/
    ThemeContext.jsx
  components/
    ThemeToggle.jsx
    Card.jsx
  App.jsx
  main.jsx
  index.css
tailwind.config.js   (v3 only)
index.html

Troubleshooting Common Issues

If dark mode is not working as expected, check these common problems:

  • Dark classes have no effect: Make sure your Tailwind config uses 'class' (v3) or the correct @custom-variant (v4). The default media strategy ignores the .dark class entirely.
  • Toggle works but resets on refresh: Verify that localStorage.setItem is being called in your useEffect, and that the initialization logic reads from localStorage first.
  • Flash of light theme on load: Add the inline script from Step 6 to your index.html.
  • Vite + React + Tailwind v4 not applying dark mode: Double-check that your CSS file includes the @custom-variant directive. This is the most common issue when migrating from v3 to v4.
  • Dark mode works on some components but not others: Ensure you are applying both light and dark classes. A missing dark: variant means the component inherits whatever the browser default is.

Performance Tips

  1. Use CSS transitions on background-color and color for smooth theme switching. The transition-colors utility in Tailwind handles this neatly.
  2. Avoid re-rendering the entire tree. Our Context approach only triggers re-renders in components that call useTheme().
  3. Keep your color palette consistent. Define a small set of semantic colors (background, surface, text-primary, text-secondary) and reuse them across components.

FAQ

How do I add dark mode to a React app with Tailwind CSS?

Set Tailwind’s dark mode strategy to “class” (v3) or use the @custom-variant directive (v4). Then toggle a dark class on the <html> element using React state. Use Tailwind’s dark: prefix on your utility classes to define dark mode styles.

How do I apply dark mode in Tailwind CSS?

Prefix any utility class with dark:. For example, bg-white dark:bg-gray-900 sets a white background in light mode and a dark gray background in dark mode. The variant activates based on the configured strategy (media query or class).

How do I persist dark mode preference in React?

Use localStorage to save the user’s selected theme. On page load, read the value from localStorage before setting the initial state. Also add an inline script in index.html to apply the dark class before React mounts, preventing a flash of the wrong theme.

How do I respect system-level dark mode preferences?

Use window.matchMedia('(prefers-color-scheme: dark)') to detect the user’s operating system preference. If no explicit preference is stored in localStorage, default to the system setting. You can also listen for changes using addEventListener('change', callback) on the media query.

Does this approach work with Tailwind CSS v4?

Yes. In Tailwind v4, the configuration has moved from tailwind.config.js to your CSS file. Use the @custom-variant dark (&:where(.dark, .dark *)) directive to enable class-based dark mode. The React code for toggling and persisting the theme remains the same.

Is Tailwind CSS shutting down?

No. Tailwind CSS is actively maintained and continues to receive updates. Tailwind v4 was released with significant performance improvements and a new engine. The framework has a strong community and is widely adopted across the industry.

Wrapping Up

Adding dark mode to a React app with Tailwind CSS is a straightforward process once you understand the moving parts. To summarize the key steps:

  1. Configure Tailwind to use the class/selector dark mode strategy.
  2. Create a ThemeContext that manages the current theme.
  3. Toggle the dark class on <html> using a useEffect hook.
  4. Persist the preference in localStorage.
  5. Respect system preferences with matchMedia.
  6. Prevent the flash of wrong theme with an inline script.

The complete implementation takes about 15 minutes and works across all modern browsers. Your users will appreciate having the choice.

If you have questions or want to share your implementation, feel free to reach out to us at boxsoftware.net. Happy coding!