coderain guide

Implementing Dark Mode in Your React Application

Dark mode has become a staple feature in modern applications, offering users a visually comfortable experience in low-light environments, reducing eye strain, and even conserving battery life on OLED screens. As a React developer, adding dark mode to your app is a valuable enhancement that improves user satisfaction. In this guide, we’ll walk through a step-by-step implementation of dark mode in a React application, covering state management, theming with CSS variables, persistence, and system preference detection.

Table of Contents

  1. Prerequisites
  2. Setting Up the Project
  3. Creating a Theme Context for State Management
  4. Using CSS Variables for Theming
  5. Implementing the Dark Mode Toggle Button
  6. Persisting Theme Preference with localStorage
  7. Handling System-Level Dark Mode Preferences
  8. Testing the Implementation
  9. Common Issues and Solutions
  10. Conclusion
  11. References

Prerequisites

Before getting started, ensure you have:

  • Basic knowledge of React (components, hooks like useState and useEffect).
  • A React project set up (we’ll use Create React App for this tutorial, but the concepts apply to Vite or Next.js).
  • Familiarity with CSS (variables, classes, and selectors).

Setting Up the Project

If you don’t already have a React project, create one using Create React App:

npx create-react-app react-dark-mode-demo
cd react-dark-mode-demo
npm start

This will set up a basic React app with a src folder containing App.js, index.js, and index.css.

Creating a Theme Context for State Management

To manage the dark mode state globally (so any component can access or toggle it), we’ll use React Context. Context allows us to pass data through the component tree without manually passing props.

Step 1: Create a Theme Context File

Create a new folder src/context and add ThemeContext.js:

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

// Create a context with a default value (optional)
const ThemeContext = createContext();

// Custom hook to use the theme context (avoids repeating useContext)
export const useTheme = () => useContext(ThemeContext);

// Provider component to wrap the app and provide theme state
export const ThemeProvider = ({ children }) => {
  // State to track dark mode (true = dark, false = light)
  const [isDarkMode, setIsDarkMode] = useState(false);

  // Function to toggle dark mode
  const toggleDarkMode = () => {
    setIsDarkMode(prev => !prev);
  };

  // Value to pass to context consumers
  const value = { isDarkMode, toggleDarkMode };

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
};

Step 2: Wrap the App with the Theme Provider

Update src/index.js to wrap your app with ThemeProvider:

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

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

Using CSS Variables for Theming

CSS variables (custom properties) are ideal for theming because they allow you to define reusable values and override them globally. We’ll define light and dark theme variables and toggle between them using a CSS class.

Step 1: Define CSS Variables in index.css

Update src/index.css to include theme variables for both light and dark modes:

/* src/index.css */
:root {
  /* Light mode variables */
  --background-color: #ffffff; /* White background */
  --text-color: #333333; /* Dark text */
  --primary-color: #007bff; /* Blue accent */
  --card-bg: #f5f5f5; /* Light gray card background */
}

/* Dark mode variables (override when .dark-mode class is applied) */
.dark-mode {
  --background-color: #1a1a1a; /* Near-black background */
  --text-color: #e0e0e0; /* Light gray text */
  --primary-color: #61dafb; /* Light blue accent (React's default) */
  --card-bg: #2d2d2d; /* Dark gray card background */
}

/* Apply variables to global elements */
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background-color: var(--background-color); /* Use background variable */
  color: var(--text-color); /* Use text variable */
  transition: background-color 0.3s ease, color 0.3s ease; /* Smooth transition */
}

/* Example card component styles using variables */
.card {
  background-color: var(--card-bg);
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  margin: 20px;
}

/* Button styles */
.toggle-button {
  background-color: var(--primary-color);
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
  transition: background-color 0.3s ease;
}

.toggle-button:hover {
  opacity: 0.9;
}

Step 2: Apply the Dark Mode Class to the DOM

To activate dark mode variables, we need to add the dark-mode class to a top-level element (e.g., body or html). We’ll do this dynamically based on the isDarkMode state from the context.

Update src/App.js to apply the class when dark mode is enabled:

// src/App.js
import { useEffect } from 'react';
import { useTheme } from './context/ThemeContext';

function App() {
  const { isDarkMode } = useTheme();

  // Apply 'dark-mode' class to body when isDarkMode is true
  useEffect(() => {
    if (isDarkMode) {
      document.body.classList.add('dark-mode');
    } else {
      document.body.classList.remove('dark-mode');
    }
  }, [isDarkMode]); // Re-run when isDarkMode changes

  return (
    <div className="App">
      <header style={{ padding: '20px', textAlign: 'center' }}>
        <h1>React Dark Mode Demo</h1>
        {/* Toggle button will go here later */}
      </header>
      <main style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
        <div className="card">
          <h2>Welcome to the Demo</h2>
          <p>This text and background should update with dark mode!</p>
          <button className="toggle-button">Toggle Dark Mode</button> {/* Placeholder */}
        </div>
      </main>
    </div>
  );
}

export default App;

Implementing the Dark Mode Toggle Button

Now, let’s replace the placeholder button with a functional toggle that uses the toggleDarkMode function from the theme context.

Update the button in App.js:

// src/App.js (updated button section)
import { useTheme } from './context/ThemeContext'; // Ensure this import exists

// Inside the App component:
const { isDarkMode, toggleDarkMode } = useTheme();

// Replace the placeholder button with:
<button 
  className="toggle-button" 
  onClick={toggleDarkMode}
>
  {isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
</button>

Now, clicking the button will toggle the isDarkMode state, which triggers the useEffect in App.js to add/remove the dark-mode class on the body. The CSS variables will update, and the theme will switch smoothly.

Persisting Theme Preference with localStorage

By default, the theme will reset to light mode when the page reloads. To persist the user’s preference, we’ll save the theme state to localStorage and load it on app startup.

Update the Theme Context to Use localStorage

Modify src/context/ThemeContext.js to load and save the theme preference:

// src/context/ThemeContext.js (updated)
import React, { createContext, useContext, useState, useEffect } from 'react';

const ThemeContext = createContext();

export const useTheme = () => useContext(ThemeContext);

export const ThemeProvider = ({ children }) => {
  const [isDarkMode, setIsDarkMode] = useState(false);

  // Load saved theme from localStorage on mount
  useEffect(() => {
    const savedTheme = localStorage.getItem('darkMode');
    if (savedTheme !== null) {
      // Parse string 'true'/'false' to boolean
      setIsDarkMode(JSON.parse(savedTheme));
    }
  }, []); // Empty dependency array: runs once on mount

  // Save theme to localStorage when it changes
  useEffect(() => {
    localStorage.setItem('darkMode', JSON.stringify(isDarkMode));
  }, [isDarkMode]); // Runs when isDarkMode changes

  const toggleDarkMode = () => {
    setIsDarkMode(prev => !prev);
  };

  const value = { isDarkMode, toggleDarkMode };

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
};

Now, the user’s theme choice will persist across page reloads and even after closing/ reopening the browser.

Handling System-Level Dark Mode Preferences

Many users enable dark mode at the system level (e.g., Windows, macOS, iOS). We can respect this preference by default if no user choice is saved in localStorage.

Update the Theme Context to Detect System Preferences

Modify the initial useEffect in ThemeContext.js to check for system preferences:

// src/context/ThemeContext.js (updated useEffect for initial load)
useEffect(() => {
  const savedTheme = localStorage.getItem('darkMode');
  if (savedTheme !== null) {
    // Use saved theme if available
    setIsDarkMode(JSON.parse(savedTheme));
  } else {
    // Fall back to system preference
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    setIsDarkMode(prefersDark);
  }
}, []);

Now, if the user hasn’t explicitly chosen a theme, the app will use their system’s dark mode setting.

Testing the Implementation

Test your dark mode implementation with these steps:

  1. Toggle Functionality: Click the “Switch to Dark Mode” button. The background, text, and accents should update immediately.
  2. Persistence: Refresh the page. The theme should remain in dark/light mode based on your last choice.
  3. System Preference:
    • On macOS: Go to System Settings > Appearance and switch between Light/Dark. The app should update if no localStorage preference exists.
    • On Windows: Go to Settings > Personalization > Colors and toggle “Choose your mode.”
  4. localStorage Check: Open browser dev tools (F12), go to the Application tab, and check localStorage for the darkMode key. It should reflect your current theme.

Common Issues and Solutions

1. Theme Doesn’t Apply on Initial Load

Issue: The app briefly shows the wrong theme before loading the saved preference.
Solution: Use a “flash prevention” technique by hiding the app until the theme is loaded. Add this to index.css:

/* Hide app until theme is applied */
#root {
  opacity: 0;
  transition: opacity 0.3s ease;
}

/* Show app after theme loads */
#root.loaded {
  opacity: 1;
}

Then, update App.js to add the loaded class after the theme is applied:

// src/App.js (add this useEffect)
useEffect(() => {
  // After theme is applied, show the app
  document.getElementById('root').classList.add('loaded');
}, [isDarkMode]);

2. CSS Variables Not Updating

Issue: Theme variables don’t change when toggling.
Solution: Ensure the dark-mode class is added to a top-level element (e.g., body). Verify the useEffect in App.js correctly adds/removes the class.

3. System Preference Not Detected

Issue: The app ignores system dark mode.
Solution: Ensure window.matchMedia('(prefers-color-scheme: dark)').matches is called correctly. Test in incognito mode (to bypass localStorage).

Conclusion

Implementing dark mode in React involves four key steps:

  1. State Management: Use React Context to share the dark mode state globally.
  2. Theming with CSS Variables: Define light/dark variables and toggle them with a CSS class.
  3. User Toggle: Add a button to switch themes and update the context state.
  4. Persistence: Save preferences to localStorage and respect system settings.

This approach ensures a smooth, maintainable dark mode implementation that works across components and persists between sessions.

References