Dark Mode Implementation: A Complete Guide to Theming Your Website
In recent years, dark mode has evolved from a developer preference to a mainstream user expectation. From operating systems to popular apps, users now anticipate the ability to switch between light and dark themes based on their environment, comfort, or aesthetic preference. Implementing a robust dark/light mode toggle on your website is no longer just a nice-to-have feature—it's a key component of modern web design that enhances usability, reduces eye strain, and provides a personalized user experience.
A well-executed theme switcher does more than just invert colors. It considers contrast ratios, preserves brand identity, respects user preferences, and maintains state across pages. In this guide, we'll walk through a systematic approach to implementing a persistent dark/light mode toggle using CSS custom properties (variables), vanilla JavaScript, and modern best practices that will work across any tech stack.
The Foundation: CSS Custom Properties (Variables)
The cornerstone of any maintainable theming system is CSS custom properties. Unlike preprocessor variables (like SASS or LESS), CSS variables are dynamic and can be changed at runtime, making them perfect for theme switching.
Defining Your Theme Variables
Instead of hard-coding color values throughout your CSS, we define them as variables on the :root element. This creates a centralized palette that we can easily modify.
css
/* Define light theme as default */
:root {
/* Background Colors */
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-elevated: #ffffff;
/* Text Colors */
--text-primary: #212529;
--text-secondary: #6c757d;
--text-muted: #adb5bd;
/* Accent/Brand Colors */
--accent-primary: #007bff;
--accent-hover: #0056b3;
/* Border Colors */
--border-color: #dee2e6;
--border-radius: 0.375rem;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Define dark theme */
[data-theme="dark"] {
--bg-primary: #121212;
--bg-secondary: #1e1e1e;
--bg-elevated: #2d2d2d;
--text-primary: #f8f9fa;
--text-secondary: #e9ecef;
--text-muted: #adb5bd;
--accent-primary: #4dabf7;
--accent-hover: #339af0;
--border-color: #495057;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
}Using the Variables in Your CSS
With our variables defined, we use them throughout our stylesheet:
css
body {
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.card {
background-color: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: var(--shadow-md);
padding: 1.5rem;
}
.btn-primary {
background-color: var(--accent-primary);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
}
.btn-primary:hover {
background-color: var(--accent-hover);
}The Toggle Switch: HTML Structure
Create an accessible toggle switch that users can interact with. This can be a simple button or a more styled checkbox approach.
html
<!-- Simple Button Version -->
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark mode">
<span class="sun-icon">☀️</span>
<span class="moon-icon">🌙</span>
</button>
<!-- Or Checkbox Version for more styling control -->
<label class="theme-switch" for="themeSwitch" aria-label="Toggle dark mode">
<input type="checkbox" id="themeSwitch">
<span class="slider"></span>
</label>Adding Functionality with JavaScript
The JavaScript handles the click interaction, theme application, and persistence using localStorage.
Basic JavaScript Implementation
javascript
class ThemeToggle {
constructor() {
this.toggleButton = document.getElementById('themeToggle');
this.currentTheme = localStorage.getItem('theme') || 'light';
this.init();
}
init() {
// Apply saved theme on page load
this.applyTheme(this.currentTheme);
// Add click event listener
this.toggleButton.addEventListener('click', () => {
this.toggleTheme();
});
}
toggleTheme() {
this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light';
this.applyTheme(this.currentTheme);
this.saveTheme(this.currentTheme);
}
applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
// Update ARIA label for accessibility
const label = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode';
this.toggleButton.setAttribute('aria-label', label);
// Optional: Update toggle button visual state
this.updateToggleVisuals(theme);
}
updateToggleVisuals(theme) {
const sunIcon = this.toggleButton.querySelector('.sun-icon');
const moonIcon = this.toggleButton.querySelector('.moon-icon');
if (theme === 'dark') {
sunIcon.style.display = 'none';
moonIcon.style.display = 'inline';
} else {
sunIcon.style.display = 'inline';
moonIcon.style.display = 'none';
}
}
saveTheme(theme) {
localStorage.setItem('theme', theme);
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new ThemeToggle();
});Respecting System Preferences
For the best user experience, we should respect the user's OS-level preference while still allowing them to override it.
javascript
class AdvancedThemeToggle extends ThemeToggle {
constructor() {
super();
this.systemPreference = this.getSystemPreference();
}
getSystemPreference() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
init() {
// If no user preference is saved, use system preference
if (!localStorage.getItem('theme')) {
this.currentTheme = this.systemPreference;
}
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
// Only update if user hasn't set a manual preference
if (!localStorage.getItem('theme')) {
this.currentTheme = e.matches ? 'dark' : 'light';
this.applyTheme(this.currentTheme);
}
});
super.init();
}
}React Implementation Example
For React developers, here's how you might implement this using hooks and context:
jsx
// ThemeContext.jsx
import React, { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');
useEffect(() => {
// Check for saved theme or system preference
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme) {
setTheme(savedTheme);
} else if (systemPrefersDark) {
setTheme('dark');
}
}, []);
useEffect(() => {
// Apply theme to document
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
// ThemeToggle.jsx
import React from 'react';
import { useTheme } from './ThemeContext';
export const ThemeToggle = () => {
const { theme, toggleTheme } = useTheme();
return (
<button
className="theme-toggle"
onClick={toggleTheme}
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? '🌙' : '☀️'}
</button>
);
};Advanced Considerations and Best Practices
Smooth Transitions
Add smooth transitions between themes by transitioning the color and background properties:
css
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* For users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
* {
transition: none;
}
}Accessibility and Contrast
Ensure your color combinations meet WCAG AA standards (minimum 4.5:1 contrast ratio for normal text)
Test both themes with screen readers
Don't rely solely on color to convey meaning
Images and Media
Consider how images look in both themes. You might want to adjust brightness or use different images:
css
.theme-aware-image {
filter: brightness(0.9);
}
[data-theme="dark"] .theme-aware-image {
filter: brightness(0.7);
}Flash of Unstyled Content (FOUC)
To prevent a flash of light theme before JavaScript loads, add a script in your <head>:
html
<script>
// Immediately set theme before DOM renders
const savedTheme = localStorage.getItem('theme');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = savedTheme || (systemPrefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
</script>Testing and Debugging
Test on multiple devices and browsers
Verify color contrast ratios using browser dev tools
Test with JavaScript disabled to ensure graceful degradation
Check that the theme persists across page navigation
Verify that system preference changes are handled correctly
Conclusion: Building for User Preference
Implementing a robust dark/light mode theme switcher is more than just a technical exercise—it's a commitment to user-centric design. By giving users control over their visual experience, you demonstrate respect for their preferences and needs. The combination of CSS variables for maintainability, JavaScript for interactivity, and localStorage for persistence creates a seamless experience that users will appreciate.
Remember that theming extends beyond just background and text colors. Consider your images, shadows, borders, and even the emotional tone different themes might convey. With the foundation we've built here, you can extend this pattern to support multiple themes, seasonal variations, or even user-customizable color schemes, taking your website's user experience to the next level.