![]()
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
- User Preference First: Respect system preferences and saved user choices
- Performance: Minimize re-renders and use efficient CSS transitions
- Accessibility: Ensure proper contrast in both modes
- Testing: Verify in multiple browsers and devices
- Documentation: Document your theme variables and usage
- Fallbacks: Provide sensible defaults for older browsers
- 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.
