javascriptroom guide

How to Create a Dark Mode Theme in React

Dark mode has become a staple feature in modern applications, offering users reduced eye strain, improved battery life (on OLED screens), and a personalized experience. Implementing dark mode in React is straightforward with the right tools—state management, CSS variables, and browser APIs. In this guide, we’ll walk through building a robust dark mode theme, from setup to persistence, with best practices and advanced tips.

Table of Contents

  1. Prerequisites
  2. Core Concepts
  3. Step-by-Step Implementation
  4. Advanced Tips
  5. Testing the Dark Mode Theme
  6. Conclusion
  7. References

Prerequisites

Before diving in, 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 guide, but Vite or Next.js works too).
  • Familiarity with CSS (variables, classes, and media queries).

Core Concepts

To build a dark mode theme, we’ll leverage three key concepts:

1. CSS Variables

CSS variables (custom properties) let you define reusable values (e.g., colors, spacing) that can be overridden globally. This makes switching themes as simple as updating variable values.

2. State Management

We’ll use React’s useState hook to track whether dark mode is active. This state will control theme changes and persist across user sessions.

3. Persistence with localStorage

To remember the user’s theme preference between visits, we’ll store the dark mode state in localStorage and load it on app initialization.

4. OS Preference Detection

Using the window.matchMedia API, we’ll respect the user’s OS-level dark mode preference (e.g., macOS or Windows settings) as a fallback.

Step-by-Step Implementation

1. Set Up Your React Project

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

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

2. Define CSS Variables for Theming

CSS variables are the foundation of our theme system. We’ll define light mode variables in the root :root selector and override them for dark mode using a dark-mode class.

Create a src/index.css file (or update your existing global CSS) with:

/* Light mode variables */
:root {
  --bg-color: #ffffff; /* Background color */
  --text-color: #333333; /* Text color */
  --primary-color: #007bff; /* Accent color (e.g., buttons, links) */
  --card-bg: #f5f5f5; /* Card/container background */
  --border-color: #e0e0e0; /* Border color */
}

/* Dark mode variables (override when .dark-mode is applied) */
.dark-mode {
  --bg-color: #1a1a1a;
  --text-color: #e0e0e0;
  --primary-color: #61dafb; /* Brighter blue for dark mode */
  --card-bg: #2d2d2d;
  --border-color: #444444;
}

/* Apply variables to global elements */
body {
  margin: 0;
  padding: 0;
  font-family: Arial, sans-serif;
  background-color: var(--bg-color);
  color: var(--text-color);
  transition: background-color 0.3s ease, color 0.3s ease; /* Smooth transition */
}

/* Example component styles using variables */
.card {
  background-color: var(--card-bg);
  border: 1px solid var(--border-color);
  padding: 1.5rem;
  border-radius: 8px;
  margin: 1rem;
}

button {
  background-color: var(--primary-color);
  color: white;
  border: none;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  cursor: pointer;
}

Here, --bg-color, --text-color, etc., define the light theme. The dark-mode class overrides these variables for dark mode.

3. Manage Dark Mode State

Next, we’ll create a state to track dark mode and apply the dark-mode class to the DOM when active.

Create a src/components/ThemeProvider.js component (we’ll use this to wrap our app and manage theme state):

import { useState, useEffect } from 'react';

const ThemeProvider = ({ children }) => {
  // State to track dark mode (default: false)
  const [isDarkMode, setIsDarkMode] = useState(false);

  // Apply/remove 'dark-mode' class to the root element
  useEffect(() => {
    if (isDarkMode) {
      document.documentElement.classList.add('dark-mode');
    } else {
      document.documentElement.classList.remove('dark-mode');
    }
  }, [isDarkMode]);

  return <>{children}</>;
};

export default ThemeProvider;

Wrap your app with ThemeProvider in src/App.js:

import ThemeProvider from './components/ThemeProvider';

function App() {
  return (
    <ThemeProvider>
      <div className="App">
        {/* Your app content here */}
      </div>
    </ThemeProvider>
  );
}

export default App;

4. Create a Theme Toggle Button

Add a button to let users switch between light and dark modes. We’ll update the ThemeProvider to include a toggle function and expose it to child components (we’ll use context later for app-wide access, but for now, we’ll keep it simple).

Update ThemeProvider.js to include a toggleDarkMode function:

import { useState, useEffect } from 'react';

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

  // Toggle dark mode state
  const toggleDarkMode = () => {
    setIsDarkMode(prev => !prev);
  };

  // Apply/remove 'dark-mode' class
  useEffect(() => {
    document.documentElement.classList.toggle('dark-mode', isDarkMode);
  }, [isDarkMode]);

  return (
    <div>
      {/* Pass toggle function to children (temporarily via props) */}
      {children({ isDarkMode, toggleDarkMode })}
    </div>
  );
};

export default ThemeProvider;

Now, use the toggle button in App.js:

import ThemeProvider from './components/ThemeProvider';

function App() {
  return (
    <ThemeProvider>
      {({ isDarkMode, toggleDarkMode }) => (
        <div className="App" style={{ minHeight: '100vh', padding: '2rem' }}>
          <h1>React Dark Mode Demo</h1>
          <div className="card">
            <p>This text uses theme variables!</p>
            <button onClick={toggleDarkMode}>
              {isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
            </button>
          </div>
        </div>
      )}
    </ThemeProvider>
  );
}

export default App;

Test it: Clicking the button should toggle between light and dark themes!

5. Persist Preferences with localStorage

To remember the user’s choice, we’ll save isDarkMode to localStorage and load it when the app mounts.

Update ThemeProvider.js with useEffect to handle persistence:

import { useState, useEffect } from 'react';

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

  // Load saved theme from localStorage on mount
  useEffect(() => {
    const savedMode = localStorage.getItem('darkMode');
    if (savedMode !== null) {
      // Parse string from localStorage to boolean
      setIsDarkMode(JSON.parse(savedMode));
    }
  }, []);

  // Save theme to localStorage when it changes
  useEffect(() => {
    localStorage.setItem('darkMode', JSON.stringify(isDarkMode));
    document.documentElement.classList.toggle('dark-mode', isDarkMode);
  }, [isDarkMode]);

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

  return <>{children({ isDarkMode, toggleDarkMode })}</>;
};

export default ThemeProvider;

Now, the theme preference will persist even after refreshing the page!

6. Respect OS-Level Preferences

If the user hasn’t set a preference (no localStorage entry), we’ll default to their OS dark mode setting using window.matchMedia('(prefers-color-scheme: dark)').

Update the initial useEffect in ThemeProvider.js:

// Load saved theme or OS preference on mount
useEffect(() => {
  const savedMode = localStorage.getItem('darkMode');
  if (savedMode !== null) {
    setIsDarkMode(JSON.parse(savedMode));
  } else {
    // Check OS preference
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    setIsDarkMode(prefersDark);
  }
}, []);

Now, new users will see the theme that matches their OS settings!

Advanced Tips

Using Context API for App-Wide Access

For larger apps, passing isDarkMode and toggleDarkMode via props (as we did earlier) leads to “prop drilling.” Instead, use React’s Context API to make the theme state globally accessible.

Create a ThemeContext.js file:

import { createContext, useContext, useState, useEffect } from 'react';

const ThemeContext = createContext();

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

  // Load from localStorage/OS preference (same as before)
  useEffect(() => {
    const savedMode = localStorage.getItem('darkMode');
    if (savedMode !== null) {
      setIsDarkMode(JSON.parse(savedMode));
    } else {
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      setIsDarkMode(prefersDark);
    }
  }, []);

  useEffect(() => {
    localStorage.setItem('darkMode', JSON.stringify(isDarkMode));
    document.documentElement.classList.toggle('dark-mode', isDarkMode);
  }, [isDarkMode]);

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

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

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

Now, wrap your app with ThemeProvider in src/index.js:

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

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

Use the theme in any component with useTheme():

// src/components/Navbar.js
import { useTheme } from '../ThemeContext';

const Navbar = () => {
  const { isDarkMode, toggleDarkMode } = useTheme();
  return (
    <nav style={{ padding: '1rem', borderBottom: '1px solid var(--border-color)' }}>
      <button onClick={toggleDarkMode}>
        {isDarkMode ? 'Light Mode' : 'Dark Mode'}
      </button>
    </nav>
  );
};

export default Navbar;

Smooth Transitions

Add CSS transitions to make theme changes feel polished. We already added transitions to the body in Step 2, but you can extend this to other elements:

/* Add transitions to all theme-aware elements */
.card, button {
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}

Theming with CSS-in-JS Libraries

If you use libraries like Styled Components or Emotion, you can define themes as objects and switch them dynamically. For example, with Styled Components:

import { ThemeProvider as StyledThemeProvider } from 'styled-components';

const lightTheme = {
  bg: '#fff',
  text: '#333',
};

const darkTheme = {
  bg: '#1a1a1a',
  text: '#e0e0e0',
};

// In your app:
<StyledThemeProvider theme={isDarkMode ? darkTheme : lightTheme}>
  {/* Components using styled-components */}
</StyledThemeProvider>

Testing the Dark Mode Theme

Verify your implementation with these checks:

  • Toggle Functionality: Clicking the button switches themes.
  • Persistence: Refresh the page—your preference should persist.
  • OS Preference: Change your OS dark mode setting (e.g., macOS System Settings > Appearance) and reload the app (ensure localStorage is cleared first).
  • Accessibility: Use tools like WebAIM Contrast Checker to ensure text meets WCAG contrast standards in both themes.

Conclusion

You’ve built a fully functional dark mode theme in React! By combining CSS variables, React state, localStorage, and OS preference detection, you’ve created a user-friendly experience that adapts to individual needs.

Customize further by adding more theme variables (e.g., --shadow-color, --font-size), supporting high-contrast modes, or integrating with design systems like Material UI.

References