Create a Dark Mode Toggle for Your Website

Give your users control over their viewing experience with a dark mode toggle. Dark mode not only reduces eye strain in low-light environments but has become an expected feature for modern websites. This tutorial will guide you through implementing a fully functional dark mode toggle with clean design, smooth transitions, and persistent settings.

Why Implement Dark Mode?

  • Reduce eye strain in low-light environments
  • Decrease battery consumption on OLED/AMOLED screens
  • Improve accessibility for users with light sensitivity
  • Follow modern design trends and user expectations
  • Enhance user experience by providing customization options

Getting Started

Before diving into the code, let’s understand what we need:

  1. A toggle mechanism (button or switch)
  2. CSS variables for light and dark themes
  3. JavaScript to handle the toggle functionality
  4. Local storage integration to remember user preference

HTML Structure

First, let’s create the toggle button:

<button id="darkModeToggle" aria-label="Toggle dark mode">
  <svg class="sun-icon" viewBox="0 0 24 24" width="24" height="24">
    <path d="M12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.65 0-3 1.35-3 3s1.35 3 3 3 3-1.35 3-3-1.35-3-3-3zm0-2V4m0 16v-3m8-8h-3m-10 0H4m14.66 6.66l-2.12-2.12M7.46 7.46L5.34 5.34m12.32.01l-2.12 2.12M7.46 16.54l-2.12 2.12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
  </svg>
  <svg class="moon-icon" viewBox="0 0 24 24" width="24" height="24">
    <path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.5 5.5 0 0 1-4.9-8.54c-1.24-.21-2.53-.1-3.72.34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
  </svg>
</button>

CSS Setup with Variables

Create a foundation with CSS variables that will change based on the theme:

:root {
  /* Light theme (default) */
  --background-primary: #ffffff;
  --background-secondary: #f5f5f5;
  --text-primary: #333333;
  --text-secondary: #666666;
  --accent-color: #4a6cf7;
  --border-color: #e0e0e0;
  --card-bg: #ffffff;
  --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  
  /* Transition for smooth theme switching */
  --transition-speed: 0.3s;
}

[data-theme="dark"] {
  /* Dark theme */
  --background-primary: #121212;
  --background-secondary: #1e1e1e;
  --text-primary: #e0e0e0;
  --text-secondary: #b0b0b0;
  --accent-color: #6d8aff;
  --border-color: #404040;
  --card-bg: #1e1e1e;
  --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}

/* Apply variables to elements */
body {
  background-color: var(--background-primary);
  color: var(--text-primary);
  transition: background-color var(--transition-speed), 
              color var(--transition-speed);
}

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

p, li, span, label, input, textarea {
  color: var(--text-secondary);
}

a {
  color: var(--accent-color);
}

.card {
  background-color: var(--card-bg);
  border: 1px solid var(--border-color);
  box-shadow: var(--card-shadow);
}

/* Toggle button styling */
#darkModeToggle {
  background: transparent;
  border: none;
  cursor: pointer;
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-primary);
  border-radius: 50%;
  transition: background-color 0.2s;
}

#darkModeToggle:hover {
  background-color: var(--background-secondary);
}

/* Handle icon visibility */
.sun-icon {
  display: none;
}

.moon-icon {
  display: block;
}

[data-theme="dark"] .sun-icon {
  display: block;
}

[data-theme="dark"] .moon-icon {
  display: none;
}

JavaScript Implementation

Now let’s implement the toggle functionality:

// Wait for DOM to load completely
document.addEventListener('DOMContentLoaded', () => {
  const toggleButton = document.getElementById('darkModeToggle');
  
  // Check for saved theme preference or use OS preference
  const savedTheme = localStorage.getItem('theme');
  const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
  
  // Set initial theme
  if (savedTheme === 'dark' || (!savedTheme && prefersDarkMode)) {
    document.documentElement.setAttribute('data-theme', 'dark');
  }
  
  // Toggle theme when button is clicked
  toggleButton.addEventListener('click', () => {
    const currentTheme = document.documentElement.getAttribute('data-theme');
    const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
    
    // Update theme attribute
    document.documentElement.setAttribute('data-theme', newTheme);
    
    // Store preference
    localStorage.setItem('theme', newTheme);
    
    // Announce theme change to screen readers
    const message = `Switched to ${newTheme} mode`;
    announceThemeChange(message);
  });
  
  // Function to announce theme change for screen readers
  function announceThemeChange(message) {
    const announcement = document.createElement('div');
    announcement.setAttribute('aria-live', 'polite');
    announcement.setAttribute('class', 'sr-only');
    announcement.textContent = message;
    document.body.appendChild(announcement);
    
    // Remove after announcement is made
    setTimeout(() => {
      document.body.removeChild(announcement);
    }, 1000);
  }
  
  // Listen for OS theme changes
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
    // Only change theme if user hasn't manually set preference
    if (!localStorage.getItem('theme')) {
      const newTheme = e.matches ? 'dark' : 'light';
      document.documentElement.setAttribute('data-theme', newTheme);
    }
  });
});

Advanced Features

Animation for Smooth Transitions

Add this to your CSS for a smoother experience:

/* Add subtle transition to all elements */
* {
  transition: background-color var(--transition-speed),
              color var(--transition-speed),
              border-color var(--transition-speed),
              box-shadow var(--transition-speed);
}

/* Toggle button animation */
@keyframes rotate {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

#darkModeToggle svg {
  transition: all 0.3s ease;
}

#darkModeToggle:active svg {
  animation: rotate 0.5s ease;
}

Prevent Flash of Incorrect Theme

Add this script to the <head> of your HTML to prevent the flash of incorrect theme on page load:

<script>
  // Immediately set theme before page renders
  (function() {
    const savedTheme = localStorage.getItem('theme');
    const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
    
    if (savedTheme === 'dark' || (!savedTheme && prefersDarkMode)) {
      document.documentElement.setAttribute('data-theme', 'dark');
    }
  })();
</script>

React Example

import { useState, useEffect } from 'react';

function DarkModeToggle() {
  const [isDarkMode, setIsDarkMode] = useState(() => {
    const savedTheme = localStorage.getItem('theme');
    const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
    return savedTheme === 'dark' || (!savedTheme && prefersDarkMode);
  });

  useEffect(() => {
    if (isDarkMode) {
      document.documentElement.setAttribute('data-theme', 'dark');
      localStorage.setItem('theme', 'dark');
    } else {
      document.documentElement.setAttribute('data-theme', 'light');
      localStorage.setItem('theme', 'light');
    }
  }, [isDarkMode]);

  return (
    <button 
      onClick={() => setIsDarkMode(!isDarkMode)}
      aria-label={`Switch to ${isDarkMode ? 'light' : 'dark'} mode`}
      className="dark-mode-toggle"
    >
      {isDarkMode ? (
        <svg className="sun-icon" viewBox="0 0 24 24" width="24" height="24">
          {/* Sun icon path */}
        </svg>
      ) : (
        <svg className="moon-icon" viewBox="0 0 24 24" width="24" height="24">
          {/* Moon icon path */}
        </svg>
      )}
    </button>
  );
}

export default DarkModeToggle;

Vue Example

<template>
  <button 
    @click="toggleDarkMode" 
    :aria-label="`Switch to ${isDarkMode ? 'light' : 'dark'} mode`"
    class="dark-mode-toggle"
  >
    <svg v-if="isDarkMode" class="sun-icon" viewBox="0 0 24 24" width="24" height="24">
      <!-- Sun icon path -->
    </svg>
    <svg v-else class="moon-icon" viewBox="0 0 24 24" width="24" height="24">
      <!-- Moon icon path -->
    </svg>
  </button>
</template>

<script>
export default {
  data() {
    return {
      isDarkMode: false
    }
  },
  created() {
    const savedTheme = localStorage.getItem('theme');
    const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
    this.isDarkMode = savedTheme === 'dark' || (!savedTheme && prefersDarkMode);
    this.applyTheme();
  },
  methods: {
    toggleDarkMode() {
      this.isDarkMode = !this.isDarkMode;
      this.applyTheme();
    },
    applyTheme() {
      if (this.isDarkMode) {
        document.documentElement.setAttribute('data-theme', 'dark');
        localStorage.setItem('theme', 'dark');
      } else {
        document.documentElement.setAttribute('data-theme', 'light');
        localStorage.setItem('theme', 'light');
      }
    }
  }
}
</script>

Common Design Patterns

Positioned in Navigation Bar

<nav class="main-nav">
  <div class="logo">Site Name</div>
  <ul class="nav-links">
    <li><a href="/">Home</a></li>
    <li><a href="/about">About</a></li>
    <li><a href="/contact">Contact</a></li>
  </ul>
  <button id="darkModeToggle" aria-label="Toggle dark mode">
    <!-- Icons here -->
  </button>
</nav>

As Part of User Settings Menu

<div class="user-settings">
  <h3>Display Settings</h3>
  <div class="setting-item">
    <label for="darkModeToggle">Dark Mode</label>
    <div class="toggle-switch">
      <input type="checkbox" id="darkModeToggle" />
      <span class="toggle-slider"></span>
    </div>
  </div>
</div>

Testing Your Implementation

Ensure your dark mode works correctly by testing:

  1. Initial load based on user preferences
  2. Toggle functionality
  3. Persistence across page reloads
  4. Proper transition animations
  5. Check accessibility with screen readers
  6. Test across different browsers and devices

Accessibility Considerations

  • Ensure toggle has proper aria-label that updates with state
  • Maintain sufficient contrast ratios in both themes
  • Announce theme changes to screen readers
  • Ensure focus states are visible in both themes

Common Issues and Solutions

Issue: Flash of incorrect theme on page load

Solution: Use the preload script technique shown earlier to apply theme before page renders.

Issue: Some elements don’t change with the theme

Solution: Ensure all color values use CSS variables instead of hardcoded values.

Issue: Animations feel jarring

Solution: Adjust the transition duration (–transition-speed) to a more comfortable value.

Issue: Theme doesn’t persist across pages

Solution: Make sure localStorage is being read on each page load.

Final Touches

To ensure your dark mode implementation is professional and well-received, consider these final touches:

  • Add subtle transition effects when switching themes
  • Ensure all UI elements adapt appropriately
  • Test with users to get feedback on the experience
  • Consider adding a system preference option (auto mode)
  • Include visual indicators to clearly show current theme state

Conclusion

Implementing a dark mode toggle is more than just swapping colors—it’s about enhancing user experience and accessibility. By following this guide, you’ve created a robust, user-friendly dark mode implementation that respects user preferences and works seamlessly across your website.

A well-implemented dark mode can significantly improve user satisfaction and distinguish your website as one that cares about user experience. As an added bonus, your users’ eyes will thank you for those late-night browsing sessions!


Have you implemented dark mode on your website? Share your experience in the comments below!