Frontend Development

Implementing Dark Mode with CSS Variables and JavaScript: A Developer's Guide

Dark mode has become an essential feature for modern web applications, offering improved user experience, reduced eye strain, and battery savings on mobile devices. In this comprehensive guide, we'll explore how to implement dark mode using CSS variables and JavaScript for a seamless, maintainable solution.

Why CSS Variables for Dark Mode?

CSS variables provide an elegant way to manage color schemes and maintain consistency across your application. Unlike traditional CSS approaches, CSS variables allow you to define color palettes once and reuse them throughout your stylesheet, making theme switching effortless.

:root {
  --background-primary: #ffffff;
  --background-secondary: #f5f5f5;
  --text-primary: #333333;
  --text-secondary: #666666;
  --border-color: #e0e0e0;
  --accent-color: #007bff;
}

[data-theme="dark"] {
  --background-primary: #1a1a1a;
  --background-secondary: #2d2d2d;
  --text-primary: #ffffff;
  --text-secondary: #cccccc;
  --border-color: #404040;
  --accent-color: #0d6efd;
}

Setting Up the Foundation

First, let's establish our CSS foundation with a comprehensive theme system. Create a base set of variables that will be overridden when switching to dark mode:

/* Base theme variables */
:root {
  /* Color palette */
  --background-primary: #ffffff;
  --background-secondary: #f8f9fa;
  --background-tertiary: #e9ecef;
  
  --text-primary: #212529;
  --text-secondary: #6c757d;
  --text-tertiary: #adb5bd;
  
  --border-color: #dee2e6;
  --divider-color: #e9ecef;
  
  --accent-primary: #007bff;
  --accent-secondary: #6c757d;
  
  --success-color: #28a745;
  --warning-color: #ffc107;
  --danger-color: #dc3545;
  
  /* Spacing */
  --spacing-xs: 0.25rem;
  --spacing-sm: 0.5rem;
  --spacing-md: 1rem;
  --spacing-lg: 1.5rem;
  --spacing-xl: 2rem;
  
  /* Shadows */
  --shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  --shadow-hover: 0 4px 8px rgba(0, 0, 0, 0.15);
}

/* Dark theme overrides */
[data-theme="dark"] {
  --background-primary: #121212;
  --background-secondary: #1e1e1e;
  --background-tertiary: #2d2d2d;
  
  --text-primary: #ffffff;
  --text-secondary: #e0e0e0;
  --text-tertiary: #b0b0b0;
  
  --border-color: #333333;
  --divider-color: #333333;
  
  --accent-primary: #0d6efd;
  --accent-secondary: #6c757d;
  
  --success-color: #28a745;
  --warning-color: #ffc107;
  --danger-color: #dc3545;
}

JavaScript Implementation

Now let's create the JavaScript logic to handle theme switching and persistence:

class ThemeManager {
  constructor() {
    this.currentTheme = this.getStoredTheme() || this.getSystemTheme();
    this.init();
  }
  
  init() {
    this.applyTheme(this.currentTheme);
    this.setupEventListeners();
  }
  
  getSystemTheme() {
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  }
  
  getStoredTheme() {
    return localStorage.getItem('theme');
  }
  
  setStoredTheme(theme) {
    localStorage.setItem('theme', theme);
  }
  
  applyTheme(theme) {
    document.documentElement.setAttribute('data-theme', theme);
    this.currentTheme = theme;
    this.setStoredTheme(theme);
  }
  
  toggleTheme() {
    const newTheme = this.currentTheme === 'light' ? 'dark' : 'light';
    this.applyTheme(newTheme);
  }
  
  setupEventListeners() {
    // Listen for system theme changes
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
      if (!this.getStoredTheme()) {
        this.applyTheme(e.matches ? 'dark' : 'light');
      }
    });
    
    // Handle manual toggle
    document.getElementById('theme-toggle').addEventListener('click', () => {
      this.toggleTheme();
    });
  }
}

// Initialize the theme manager
document.addEventListener('DOMContentLoaded', () => {
  new ThemeManager();
});

Practical Implementation Example

Let's see how to use these variables in practical components:

/* Card component using theme variables */
.card {
  background-color: var(--background-primary);
  color: var(--text-primary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: var(--spacing-lg);
  box-shadow: var(--shadow);
  transition: all 0.3s ease;
}

.card:hover {
  box-shadow: var(--shadow-hover);
}

/* Button component */
.btn {
  background-color: var(--background-secondary);
  color: var(--text-primary);
  border: 1px solid var(--border-color);
  padding: var(--spacing-sm) var(--spacing-md);
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.2s ease;
}

.btn:hover {
  background-color: var(--background-tertiary);
}

.btn-primary {
  background-color: var(--accent-primary);
  color: white;
  border: none;
}

.btn-primary:hover {
  background-color: #0056b3;
}

/* Form elements */
.form-input {
  background-color: var(--background-secondary);
  color: var(--text-primary);
  border: 1px solid var(--border-color);
  padding: var(--spacing-sm);
  border-radius: 4px;
  transition: border-color 0.2s ease;
}

.form-input:focus {
  outline: none;
  border-color: var(--accent-primary);
}

Advanced Features and Considerations

For a more robust implementation, consider these advanced features:

  • Smooth transitions between themes
  • Support for system preference detection
  • Theme persistence across sessions
  • Accessibility considerations
/* Smooth transition for theme changes */
[data-theme] {
  transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}

/* Ensure good contrast for accessibility */
[data-theme="dark"] .text-contrast {
  color: #ffffff !important;
}

/* Focus states for accessibility */
*:focus {
  outline: 2px solid var(--accent-primary);
  outline-offset: 2px;
}

Testing and Validation

Always test your implementation across different scenarios:

// Test function to verify theme application
function testTheme() {
  const root = document.documentElement;
  const computedStyles = getComputedStyle(root);
  
  console.log('Current theme:', root.getAttribute('data-theme'));
  console.log('Background color:', computedStyles.getPropertyValue('--background-primary'));
  console.log('Text color:', computedStyles.getPropertyValue('--text-primary'));
  
  // Verify contrast ratios meet WCAG standards
  const bgColor = computedStyles.getPropertyValue('--background-primary');
  const textColor = computedStyles.getPropertyValue('--text-primary');
  console.log('Color contrast:', calculateContrastRatio(bgColor, textColor));
}

// Simple contrast ratio calculator
function calculateContrastRatio(color1, color2) {
  // Implementation would involve converting hex to RGB and calculating ratio
  // This is a simplified example
  return 'Passes WCAG standards';
}

Conclusion

Implementing dark mode with CSS variables and JavaScript creates a maintainable, performant solution that enhances user experience. By leveraging CSS variables, you can create a consistent design system that adapts seamlessly to user preferences. The key benefits include:

  • Reduced code duplication - Single source of truth for colors
  • Better performance - CSS variables are processed by the browser efficiently
  • Easier maintenance - Theme changes require minimal code modification
  • Enhanced user experience - Responsive to system preferences and user choice

Remember to test thoroughly across different devices and browsers, and consider accessibility guidelines when implementing your theme system. With this approach, your web application will provide a polished, professional experience that adapts to user preferences seamlessly.

Share: