calendar_month : August 11, 2025

A Complete Guide to Building a Dark Mode Feature with CSS & JavaScript

Table of Contents

  1. Dark theme
  2. Why Dark Mode Matters in 2025
  3. Understanding Dark Mode Implementation
  4. CSS Custom Properties for Dark Mode
  5. JavaScript Toggle Functionality
  6. Advanced Dark Mode Techniques
  7. User Preference Detection
  8. Accessibility Considerations
  9. Performance Optimization
  10. Best Practices and Common Pitfalls
  11. Testing and Browser Compatibility
  12. Real-World Examples
  13. Conclusion

Introduction

Dark mode has evolved from a nice-to-have feature to an essential part of modern web design. With major platforms like Twitter, GitHub, and Google implementing dark themes, users now expect this functionality across all digital experiences.

This comprehensive guide will walk you through building a robust, accessible, and performant dark mode feature using modern CSS and JavaScript techniques. You’ll learn everything from basic implementation to advanced optimization strategies.

Why Dark Mode Matters in 2025

User Experience Benefits

Dark mode isn’t just a trend – it provides tangible benefits for users:

Design and Business Impact

  • Modern Appeal: 82% of smartphone users prefer apps with dark mode options
  • User Retention: Apps with dark mode see 15% higher engagement rates
  • Accessibility: Supports users with light sensitivity and visual impairments
  • Brand Differentiation: Professional, modern appearance that stands out

Understanding Dark Mode Implementation

Core Concepts

Before diving into code, let’s understand the fundamental approaches to implementing dark mode:

1. CSS Custom Properties (Recommended)

Using CSS custom properties (CSS variables) for dynamic theme switching.

2. CSS Classes

Traditional approach using separate CSS classes for light and dark themes.

3. CSS Media Queries

Automatic detection using prefers-color-scheme media query.

Implementation Strategy

The most effective approach combines all three methods:

  1. System preference detection for initial theme
  2. CSS custom properties for smooth transitions
  3. JavaScript for user controls and persistence

CSS Custom Properties for Dark Mode

Setting Up Color Variables

First, define your color palette using CSS custom properties:

css
:root {
  /* Light theme colors */
  --bg-primary: #ffffff;
  --bg-secondary: #f8f9fa;
  --text-primary: #212529;
  --text-secondary: #6c757d;
  --border-color: #dee2e6;
  --accent-color: #007bff;
  --shadow: rgba(0, 0, 0, 0.1);
}

[data-theme="dark"] {
  /* Dark theme colors */
  --bg-primary: #121212;
  --bg-secondary: #1e1e1e;
  --text-primary: #ffffff;
  --text-secondary: #b0b0b0;
  --border-color: #333333;
  --accent-color: #4dabf7;
  --shadow: rgba(255, 255, 255, 0.1);
}

Applying Variables to Elements

Use these variables throughout your 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-secondary);
  border: 1px solid var(--border-color);
  box-shadow: 0 2px 4px var(--shadow);
  transition: all 0.3s ease;
}

.btn-primary {
  background-color: var(--accent-color);
  color: var(--bg-primary);
}

/* Text elements */
h1, h2, h3, h4, h5, h6 {
  color: var(--text-primary);
}

p, span, div {
  color: var(--text-primary);
}

.text-muted {
  color: var(--text-secondary);
}

Advanced Color Calculations

For more sophisticated theming, use CSS calc() and color functions:

css
:root {
  --primary-hue: 210;
  --primary-saturation: 100%;
}

[data-theme="light"] {
  --bg-primary: hsl(var(--primary-hue), 0%, 100%);
  --bg-secondary: hsl(var(--primary-hue), 10%, 98%);
  --text-primary: hsl(var(--primary-hue), 5%, 10%);
}

[data-theme="dark"] {
  --bg-primary: hsl(var(--primary-hue), 5%, 7%);
  --bg-secondary: hsl(var(--primary-hue), 8%, 12%);
  --text-primary: hsl(var(--primary-hue), 5%, 95%);
}

JavaScript Toggle Functionality

Basic Toggle Implementation

Here’s a complete JavaScript implementation for dark mode toggle:

javascript
class DarkModeToggle {
  constructor() {
    this.theme = localStorage.getItem('theme') || 'light';
    this.toggleBtn = document.querySelector('[data-theme-toggle]');
    this.init();
  }

  init() {
    // Set initial theme
    this.setTheme(this.theme);
    
    // Add event listener
    if (this.toggleBtn) {
      this.toggleBtn.addEventListener('click', () => this.toggle());
    }
    
    // Listen for system preference changes
    window.matchMedia('(prefers-color-scheme: dark)')
      .addEventListener('change', (e) => this.handleSystemChange(e));
  }

  setTheme(theme) {
    this.theme = theme;
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('theme', theme);
    this.updateToggleButton();
    
    // Dispatch custom event
    document.dispatchEvent(new CustomEvent('themechange', {
      detail: { theme }
    }));
  }

  toggle() {
    const newTheme = this.theme === 'light' ? 'dark' : 'light';
    this.setTheme(newTheme);
  }

  updateToggleButton() {
    if (this.toggleBtn) {
      this.toggleBtn.setAttribute('aria-label', 
        `Switch to ${this.theme === 'light' ? 'dark' : 'light'} mode`);
      
      // Update button icon or text
      const icon = this.toggleBtn.querySelector('.theme-icon');
      if (icon) {
        icon.textContent = this.theme === 'light' ? '🌙' : '☀️';
      }
    }
  }

  handleSystemChange(e) {
    if (!localStorage.getItem('theme')) {
      this.setTheme(e.matches ? 'dark' : 'light');
    }
  }

  // Get current theme
  getCurrentTheme() {
    return this.theme;
  }

  // Check if dark mode is active
  isDarkMode() {
    return this.theme === 'dark';
  }
}

// Initialize dark mode toggle
document.addEventListener('DOMContentLoaded', () => {
  window.darkMode = new DarkModeToggle();
});

HTML Toggle Button

Create an accessible toggle button:

html
<button 
  data-theme-toggle
  class="theme-toggle"
  aria-label="Switch to dark mode"
  type="button"
>
  <span class="theme-icon" aria-hidden="true">🌙</span>
  <span class="sr-only">Toggle theme</span>
</button>

CSS for Toggle Button

Style the toggle button:

css
.theme-toggle {
  background: var(--bg-secondary);
  border: 2px solid var(--border-color);
  border-radius: 50%;
  width: 48px;
  height: 48px;
  cursor: pointer;
  transition: all 0.3s ease;
  display: flex;
  align-items: center;
  justify-content: center;
}

.theme-toggle:hover {
  background: var(--accent-color);
  color: var(--bg-primary);
  transform: scale(1.1);
}

.theme-toggle:focus {
  outline: 2px solid var(--accent-color);
  outline-offset: 2px;
}

.theme-icon {
  font-size: 1.2rem;
  transition: transform 0.3s ease;
}

.theme-toggle:hover .theme-icon {
  transform: rotate(20deg);
}

/* Screen reader only content */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

Advanced Dark Mode Techniques

Smooth Transitions

Implement smooth theme transitions to enhance user experience:

css
* {
  transition: 
    background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
    border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
    color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
    box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Disable transitions during theme change to prevent flash */
.theme-transition {
  transition: none !important;
}

JavaScript to handle smooth transitions:

javascript
setTheme(theme) {
  // Temporarily disable transitions
  document.body.classList.add('theme-transition');
  
  this.theme = theme;
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
  
  // Re-enable transitions after a brief delay
  setTimeout(() => {
    document.body.classList.remove('theme-transition');
  }, 50);
  
  this.updateToggleButton();
}

Image and Media Handling

Handle images and media elements in dark mode:

css
/* Invert images in dark mode */
[data-theme="dark"] img:not(.no-invert) {
  filter: brightness(0.8) contrast(1.2);
}

/* Specific handling for logos */
[data-theme="dark"] .logo {
  filter: brightness(0) invert(1);
}

/* Video and iframe adjustments */
[data-theme="dark"] video,
[data-theme="dark"] iframe {
  filter: brightness(0.9);
}

CSS Grid and Flexbox Considerations

Ensure layout components work well with theme changes:

css
.grid-container {
  display: grid;
  gap: 1rem;
  background: var(--bg-secondary);
  border: 1px solid var(--border-color);
}

.flex-container {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  background: linear-gradient(
    135deg, 
    var(--bg-primary) 0%, 
    var(--bg-secondary) 100%
  );
}

User Preference Detection

System Preference Detection

Detect and respect user’s system preferences:

javascript
// Check system preference
function getSystemPreference() {
  return window.matchMedia('(prefers-color-scheme: dark)').matches 
    ? 'dark' 
    : 'light';
}

// Enhanced initialization
class AdvancedDarkModeToggle extends DarkModeToggle {
  constructor() {
    super();
    this.systemPreference = getSystemPreference();
    this.userPreference = localStorage.getItem('theme');
  }

  init() {
    // Priority: User preference > System preference > Default (light)
    const initialTheme = this.userPreference || this.systemPreference || 'light';
    this.setTheme(initialTheme);
    
    super.init();
  }

  // Auto theme based on time
  getAutoTheme() {
    const hour = new Date().getHours();
    return (hour >= 18 || hour <= 6) ? 'dark' : 'light';
  }
}

CSS Media Query Integration

Use CSS to provide fallback styling:

css
/* Default light theme */
:root {
  --bg-primary: #ffffff;
  --text-primary: #000000;
}

/* System dark preference fallback */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg-primary: #121212;
    --text-primary: #ffffff;
  }
}

/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
  * {
    transition: none !important;
  }
}

Accessibility Considerations

ARIA Labels and Screen Readers

Make your dark mode toggle accessible:

html
<button 
  data-theme-toggle
  class="theme-toggle"
  aria-label="Toggle between light and dark mode"
  aria-pressed="false"
  type="button"
>
  <svg aria-hidden="true" class="theme-icon">
    <!-- SVG icon content -->
  </svg>
</button>

Updated JavaScript for accessibility:

javascript
updateToggleButton() {
  if (this.toggleBtn) {
    const isDark = this.theme === 'dark';
    
    this.toggleBtn.setAttribute('aria-label', 
      `Switch to ${isDark ? 'light' : 'dark'} mode`);
    this.toggleBtn.setAttribute('aria-pressed', isDark.toString());
    
    // Update icon
    const icon = this.toggleBtn.querySelector('.theme-icon');
    if (icon) {
      icon.innerHTML = isDark 
        ? '<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>'
        : '<path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>';
    }
  }
}

Color Contrast Compliance

Ensure WCAG 2.1 compliance:

css
:root {
  /* Light theme - WCAG AA compliant */
  --bg-primary: #ffffff;
  --text-primary: #212529; /* Contrast ratio: 16.8:1 */
  --text-secondary: #6c757d; /* Contrast ratio: 4.5:1 */
  --accent-color: #0056b3; /* Contrast ratio: 7.1:1 */
}

[data-theme="dark"] {
  /* Dark theme - WCAG AA compliant */
  --bg-primary: #121212;
  --text-primary: #e0e0e0; /* Contrast ratio: 15.8:1 */
  --text-secondary: #b0b0b0; /* Contrast ratio: 7.3:1 */
  --accent-color: #66b3ff; /* Contrast ratio: 8.2:1 */
}

/* High contrast mode support */
@media (prefers-contrast: high) {
  :root {
    --text-primary: #000000;
    --bg-primary: #ffffff;
  }
  
  [data-theme="dark"] {
    --text-primary: #ffffff;
    --bg-primary: #000000;
  }
}

Focus Management

Ensure keyboard navigation works seamlessly:

css
.theme-toggle:focus-visible {
  outline: 2px solid var(--accent-color);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(var(--accent-color), 0.2);
}

/* High contrast focus indicators */
@media (prefers-contrast: high) {
  .theme-toggle:focus-visible {
    outline-width: 3px;
    outline-color: var(--text-primary);
  }
}

Performance Optimization

Minimize Layout Shifts

Prevent Cumulative Layout Shift (CLS) during theme changes:

css
/* Reserve space for elements that might change size */
.theme-toggle {
  width: 48px;
  height: 48px;
  min-width: 48px;
  min-height: 48px;
}

/* Use transform instead of changing properties that trigger layout */
.card {
  transform: translateZ(0); /* Force GPU acceleration */
}

.card:hover {
  transform: translateY(-2px) translateZ(0);
}

Efficient CSS Loading

Optimize CSS delivery:

html
<!-- Critical CSS inline -->
<style>
  /* Critical dark mode styles inline */
  :root { --bg-primary: #ffffff; }
  [data-theme="dark"] { --bg-primary: #121212; }
  body { background-color: var(--bg-primary); }
</style>

<!-- Non-critical CSS -->
<link rel="preload" href="dark-mode.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="dark-mode.css"></noscript>

JavaScript Performance

Optimize the toggle implementation:

javascript
class OptimizedDarkModeToggle {
  constructor() {
    this.theme = this.getStoredTheme();
    this.toggleBtn = null;
    this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    this.init();
  }

  init() {
    // Use requestAnimationFrame for smooth updates
    requestAnimationFrame(() => {
      this.setTheme(this.theme);
      this.bindEvents();
    });
  }

  bindEvents() {
    // Use event delegation
    document.addEventListener('click', this.handleClick.bind(this));
    
    // Throttle system preference changes
    this.mediaQuery.addEventListener('change', 
      this.throttle(this.handleSystemChange.bind(this), 100));
  }

  handleClick(e) {
    if (e.target.matches('[data-theme-toggle]')) {
      e.preventDefault();
      this.toggle();
    }
  }

  throttle(func, wait) {
    let timeout;
    return function executedFunction(...args) {
      const later = () => {
        clearTimeout(timeout);
        func(...args);
      };
      clearTimeout(timeout);
      timeout = setTimeout(later, wait);
    };
  }

  getStoredTheme() {
    try {
      return localStorage.getItem('theme') || 
        (this.mediaQuery.matches ? 'dark' : 'light');
    } catch (e) {
      return 'light';
    }
  }
}

Best Practices and Common Pitfalls

Do’s and Don’ts

✅ Do:

  • Use CSS custom properties for maintainable theming
  • Respect system preferences by default
  • Implement smooth transitions
  • Test color contrast ratios
  • Provide keyboard accessibility
  • Store user preferences

❌ Don’t:

  • Invert all colors automatically
  • Ignore accessibility guidelines
  • Forget to handle images and media
  • Use only black and white
  • Neglect mobile experience
  • Skip browser testing

Common Mistakes

1. Poor Color Choices

css
/* ❌ Poor contrast */
[data-theme="dark"] {
  --text-color: #888888; /* Low contrast */
  --bg-color: #333333;
}

/* ✅ Good contrast */
[data-theme="dark"] {
  --text-color: #e0e0e0; /* High contrast */
  --bg-color: #121212;
}

2. Missing System Integration

javascript
// ❌ Ignoring system preference
const theme = localStorage.getItem('theme') || 'light';

// ✅ Respecting system preference
const systemPrefers = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = localStorage.getItem('theme') || (systemPrefers ? 'dark' : 'light');

3. Inadequate Image Handling

css
/* ❌ Inverting all images */
[data-theme="dark"] img {
  filter: invert(1);
}

/* ✅ Selective image handling */
[data-theme="dark"] .content-image {
  filter: brightness(0.8) contrast(1.2);
}

[data-theme="dark"] .logo {
  filter: brightness(0) invert(1);
}

Testing and Browser Compatibility

Cross-Browser Testing

Test your dark mode implementation across browsers:

  • Chrome/Edge: Full support for CSS custom properties and prefers-color-scheme
  • Firefox: Excellent support with good developer tools
  • Safari: Good support, test on both desktop and mobile
  • Internet Explorer: Limited support, provide fallbacks

Browser Compatibility Fallbacks

css
/* Fallback for older browsers */
.theme-light {
  background-color: #ffffff;
  color: #000000;
}

.theme-dark {
  background-color: #121212;
  color: #ffffff;
}

/* Modern browsers with CSS custom properties */
@supports (color: var(--custom)) {
  .theme-light,
  .theme-dark {
    background-color: var(--bg-primary);
    color: var(--text-primary);
  }
}

Testing Checklist

  •  Theme toggle works correctly
  •  System preference detection
  •  Local storage persistence
  •  Keyboard navigation
  •  Screen reader compatibility
  •  Color contrast ratios (use WebAIM Contrast Checker)
  •  Mobile responsiveness
  •  Performance impact
  •  Image and media handling
  •  Form element styling

Real-World Examples

GitHub-Style Implementation

css
/* GitHub-inspired dark mode */
:root {
  --color-canvas-default: #ffffff;
  --color-canvas-subtle: #f6f8fa;
  --color-fg-default: #24292f;
  --color-fg-muted: #656d76;
  --color-border-default: #d0d7de;
  --color-accent-emphasis: #0969da;
}

[data-color-mode="dark"] {
  --color-canvas-default: #0d1117;
  --color-canvas-subtle: #161b22;
  --color-fg-default: #f0f6fc;
  --color-fg-muted: #7d8590;
  --color-border-default: #30363d;
  --color-accent-emphasis: #2f81f7;
}

body {
  background-color: var(--color-canvas-default);
  color: var(--color-fg-default);
}

.header {
  background-color: var(--color-canvas-subtle);
  border-bottom: 1px solid var(--color-border-default);
}

Material Design 3 Approach

css
/* Material Design 3 color system */
:root {
  /* Light theme */
  --md-sys-color-primary: #6750a4;
  --md-sys-color-surface: #fffbfe;
  --md-sys-color-on-surface: #1c1b1f;
  --md-sys-color-surface-variant: #e7e0ec;
  --md-sys-color-outline: #79747e;
}

[data-theme="dark"] {
  /* Dark theme */
  --md-sys-color-primary: #d0bcff;
  --md-sys-color-surface: #1c1b1f;
  --md-sys-color-on-surface: #e6e1e5;
  --md-sys-color-surface-variant: #49454f;
  --md-sys-color-outline: #938f99;
}

.card {
  background-color: var(--md-sys-color-surface);
  color: var(--md-sys-color-on-surface);
  border: 1px solid var(--md-sys-color-outline);
}

Conclusion

Implementing a robust dark mode feature requires careful consideration of user experience, accessibility, and performance. By following the techniques and best practices outlined in this guide, you’ll create a dark mode implementation that not only looks great but also provides genuine value to your users.

Key takeaways for successful dark mode implementation:

  1. Start with user needs – Respect system preferences and provide easy controls
  2. Use CSS custom properties – They provide the most flexible and maintainable approach
  3. Prioritize accessibility – Ensure proper contrast ratios and keyboard navigation
  4. Test thoroughly – Check across browsers, devices, and with assistive technologies
  5. Optimize performance – Minimize layout shifts and use efficient CSS/JavaScript

Remember that dark mode is not just about inverting colors – it’s about creating a cohesive, accessible, and delightful user experience that works in any lighting condition

Useful Resources: