Implementing Dark Mode with CSS Variables

Loading

Dark mode has become an essential feature for modern web applications. Here’s how to implement a robust dark mode solution using CSS variables in React that’s performant, accessible, and maintainable.

1. Basic Dark Mode Setup

CSS Variables Definition

/* styles.css */
:root {
  /* Light mode colors */
  --color-bg: #ffffff;
  --color-text: #222222;
  --color-primary: #646cff;
  --color-border: #e0e0e0;

  /* Dark mode colors */
  --dark-color-bg: #121212;
  --dark-color-text: #f5f5f5;
  --dark-color-primary: #a5b4fc;
  --dark-color-border: #333333;
}

body {
  background-color: var(--color-bg);
  color: var(--color-text);
  transition: background-color 0.3s ease, color 0.3s ease;
}

React Toggle Component

import { useState, useEffect } from 'react';

function DarkModeToggle() {
  const [darkMode, setDarkMode] = useState(() => {
    // Check localStorage for saved preference
    const savedMode = localStorage.getItem('darkMode');
    return savedMode ? JSON.parse(savedMode) : false;
  });

  useEffect(() => {
    // Apply class to document root
    if (darkMode) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
    // Save preference
    localStorage.setItem('darkMode', JSON.stringify(darkMode));
  }, [darkMode]);

  return (
    <button
      onClick={() => setDarkMode(!darkMode)}
      aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
    >
      {darkMode ? '☀️' : '🌙'}
    </button>
  );
}

Enhanced CSS with Dark Mode Class

/* styles.css */
.dark {
  --color-bg: var(--dark-color-bg);
  --color-text: var(--dark-color-text);
  --color-primary: var(--dark-color-primary);
  --color-border: var(--dark-color-border);
}

2. Advanced Implementation with Context

Dark Mode Context

// DarkModeContext.js
import { createContext, useContext, useState, useEffect } from 'react';

const DarkModeContext = createContext();

export function DarkModeProvider({ children }) {
  const [darkMode, setDarkMode] = useState(() => {
    // Check for saved preference or system preference
    const savedMode = localStorage.getItem('darkMode');
    if (savedMode !== null) return JSON.parse(savedMode);
    return window.matchMedia('(prefers-color-scheme: dark)').matches;
  });

  useEffect(() => {
    // Apply class to document root
    const root = document.documentElement;
    if (darkMode) {
      root.classList.add('dark');
    } else {
      root.classList.remove('dark');
    }
    // Save preference
    localStorage.setItem('darkMode', JSON.stringify(darkMode));
  }, [darkMode]);

  const toggleDarkMode = () => setDarkMode(!darkMode);

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

export const useDarkMode = () => useContext(DarkModeContext);

Using the Context

function ThemeToggleButton() {
  const { darkMode, toggleDarkMode } = useDarkMode();

  return (
    <button onClick={toggleDarkMode} className="theme-toggle">
      {darkMode ? (
        <SunIcon className="w-5 h-5" />
      ) : (
        <MoonIcon className="w-5 h-5" />
      )}
      <span className="sr-only">
        {darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
      </span>
    </button>
  );
}

3. System Preference Detection

Enhanced Initial State Check

const [darkMode, setDarkMode] = useState(() => {
  // Check localStorage first
  const savedMode = localStorage.getItem('darkMode');
  if (savedMode !== null) return JSON.parse(savedMode);

  // Fallback to system preference
  return window.matchMedia('(prefers-color-scheme: dark)').matches;
});

Listening to System Preference Changes

useEffect(() => {
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

  const handleChange = (e) => {
    // Only update if no explicit preference is set
    if (localStorage.getItem('darkMode') === null) {
      setDarkMode(e.matches);
    }
  };

  mediaQuery.addEventListener('change', handleChange);
  return () => mediaQuery.removeEventListener('change', handleChange);
}, []);

4. Smooth Transitions and Accessibility

CSS Transition Enhancements

/* styles.css */
* {
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}

/* Disable transitions for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
  * {
    transition: none !important;
  }
}

Accessible Toggle Button

function AccessibleToggle() {
  const { darkMode, toggleDarkMode } = useDarkMode();

  return (
    <button
      onClick={toggleDarkMode}
      aria-pressed={darkMode}
      className="p-2 rounded-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-dark-bg"
    >
      <span className="sr-only">
        {darkMode ? 'Disable dark mode' : 'Enable dark mode'}
      </span>
      {darkMode ? <SunIcon /> : <MoonIcon />}
    </button>
  );
}

5. Theming with CSS-in-JS (Styled Components)

Styled Components Implementation

import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';

const lightTheme = {
  bg: '#ffffff',
  text: '#222222',
  primary: '#646cff',
  border: '#e0e0e0',
};

const darkTheme = {
  bg: '#121212',
  text: '#f5f5f5',
  primary: '#a5b4fc',
  border: '#333333',
};

const GlobalStyle = createGlobalStyle`
  body {
    background-color: ${({ theme }) => theme.bg};
    color: ${({ theme }) => theme.text};
    transition: background-color 0.3s ease, color 0.3s ease;
  }
`;

function ThemedApp() {
  const { darkMode } = useDarkMode();

  return (
    <ThemeProvider theme={darkMode ? darkTheme : lightTheme}>
      <GlobalStyle />
      <AppContent />
    </ThemeProvider>
  );
}

6. Performance Optimizations

Preventing Flash of Default Theme

// In _document.js (Next.js) or index.html
<script dangerouslySetInnerHTML={{
  __html: `
    (function() {
      const savedMode = localStorage.getItem('darkMode');
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const isDark = savedMode ? JSON.parse(savedMode) : systemDark;

      if (isDark) {
        document.documentElement.classList.add('dark');
      }
    })();
  `
}} />

CSS Variable Optimization

/* Only transition color properties that actually change */
:root {
  --transition-speed: 0.3s;
  --transition-easing: ease;
}

body {
  transition:
    background-color var(--transition-speed) var(--transition-easing),
    color var(--transition-speed) var(--transition-easing);
}

/* More specific transitions for elements that need them */
button, a, input, textarea {
  transition:
    background-color var(--transition-speed) var(--transition-easing),
    color var(--transition-speed) var(--transition-easing),
    border-color var(--transition-speed) var(--transition-easing);
}

7. Extending the Theme System

Multi-Theme Support

const themes = {
  light: {
    '--color-bg': '#ffffff',
    '--color-text': '#222222',
    '--color-primary': '#646cff',
  },
  dark: {
    '--color-bg': '#121212',
    '--color-text': '#f5f5f5',
    '--color-primary': '#a5b4fc',
  },
  sepia: {
    '--color-bg': '#f4ecd8',
    '--color-text': '#5b4636',
    '--color-primary': '#d35400',
  }
};

function applyTheme(themeName) {
  const theme = themes[themeName];
  Object.entries(theme).forEach(([key, value]) => {
    document.documentElement.style.setProperty(key, value);
  });
}

Theme Persistence

const [theme, setTheme] = useState(() => {
  const savedTheme = localStorage.getItem('theme');
  return savedTheme || 'system';
});

useEffect(() => {
  if (theme === 'system') {
    const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    applyTheme(isDark ? 'dark' : 'light');
  } else {
    applyTheme(theme);
  }
  localStorage.setItem('theme', theme);
}, [theme]);

Best Practices for Dark Mode Implementation

  1. User Preference First: Respect system preferences and saved user choices
  2. Performance: Minimize re-renders and use efficient CSS transitions
  3. Accessibility: Ensure proper contrast in both modes
  4. Testing: Verify in multiple browsers and devices
  5. Documentation: Document your theme variables and usage
  6. Fallbacks: Provide sensible defaults for older browsers
  7. Consistency: Maintain consistent theming across all components

Complete Example with Next.js

_app.js Implementation

import { DarkModeProvider } from '../context/DarkModeContext';
import '../styles/globals.css';

function MyApp({ Component, pageProps }) {
  return (
    <DarkModeProvider>
      <Component {...pageProps} />
    </DarkModeProvider>
  );
}

export default MyApp;

Custom Hook

// hooks/useDarkMode.js
import { useEffect, useState } from 'react';

export function useDarkMode() {
  const [darkMode, setDarkMode] = useState(false);

  useEffect(() => {
    // Check for saved preference
    const savedMode = localStorage.getItem('darkMode');
    if (savedMode !== null) {
      setDarkMode(JSON.parse(savedMode));
    } else {
      // Check system preference
      setDarkMode(
        window.matchMedia('(prefers-color-scheme: dark)').matches
      );
    }
  }, []);

  useEffect(() => {
    if (darkMode) {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
    localStorage.setItem('darkMode', JSON.stringify(darkMode));
  }, [darkMode]);

  const toggleDarkMode = () => setDarkMode(!darkMode);

  return { darkMode, toggleDarkMode };
}

Tailwind CSS Integration

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        light: {
          bg: '#ffffff',
          text: '#222222',
          primary: '#646cff',
        },
        dark: {
          bg: '#121212',
          text: '#f5f5f5',
          primary: '#a5b4fc',
        },
      },
    },
  },
};

By implementing dark mode with CSS variables in this way, you create a flexible, performant, and maintainable theming system that can be easily extended and adapted to your application’s needs. The solution respects user preferences, works server-side, and provides a smooth visual experience.

Leave a Reply

Your email address will not be published. Required fields are marked *