DEV Community

Paul
Paul

Posted on

Build a Dark Mode Toggle in 5 Minutes (That Actually Works)

Ever spent hours implementing a dark mode toggle only to have it flash blindingly white on page refresh? Or worse, have it completely ignore your user's system preferences? Yeah, me too. 😅

Here's the thing - dark mode isn't just a trendy feature anymore. With more people coding at night (guilty as charged) and accessibility becoming increasingly important, a well-implemented dark mode is practically a must-have for modern websites or web apps. But getting it right can be surprisingly tricky.

The good news? After fumbling through various implementations and fighting with localStorage, I've finally cracked the code for a dark mode toggle that:

  • Actually remembers your user's preference
  • Doesn't flash the wrong theme on reload
  • Plays nicely with system preferences
  • Takes literally 5 minutes to implement

In this post, I'll walk you through building a dark mode toggle that you'll actually want to use. No overcomplicated solutions, no unnecessary dependencies - just clean, working code that you can implement right away.

Prerequisites (The Stuff You'll Need)

Let's get the boring part out of the way first - but I promise to keep it short!

You probably already have everything you need, but just to make sure we're on the same page:

  • Basic HTML (like, you know what a <button> is)
  • Some CSS knowledge (especially CSS variables - but I'll explain as we go)
  • Vanilla JavaScript (nothing fancy, I promise)
  • Your favorite code editor
  • About 5 minutes of your time (and maybe a coffee ☕)

What We're Building

Before we jump in, here's a quick peek at what we'll end up with. No fancy UI libraries or complicated setups - just a simple, smooth toggle that looks something like this:

Don't worry about making it look exactly like this - the important part is that it'll work flawlessly. We'll focus on function first, then you can style it however you want.

The best part? Everything we're about to build works with:

  • Modern browsers (yes, even Safari!)
  • System dark mode preferences
  • Page refreshes (no more flash of white screen)
  • Zero external dependencies

Ready to get your hands dirty? Let's start with the foundation!

Setting Up the Foundation

Alright, let's get our hands dirty! First, we'll set up the basic structure.

The HTML: Keep It Simple

We'll start with some dead-simple HTML. No need to overthink this part:

<button class="theme-toggle" aria-label="Toggle dark mode">
  <svg class="sun-icon" /* We'll add the icon paths in a bit */></svg>
  <svg class="moon-icon" /* Same here */></svg>
</button>

<!-- The rest of your content goes here -->
<main class="content">
  <h1>Your awesome content</h1>
  <!-- You get the idea... -->
</main>
Enter fullscreen mode Exit fullscreen mode

The CSS

Here's where things get interesting. We'll use CSS variables (aka custom properties) to handle our color scheme. Drop this in your CSS file:

:root {
  --background: #ffffff;
  --text-primary: #222222;
  --toggle-bg: #e4e4e7;
  --toggle-hover: #d4d4d8;
}


[data-theme="dark"] {
  --background: #121212;
  --text-primary: #ffffff;
  --toggle-bg: #3f3f46;
  --toggle-hover: #52525b;
}

body {
  background-color: var(--background);
  color: var(--text-primary);
  transition: background-color 0.3s ease, color 0.3s ease;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

.theme-toggle {
  border: none;
  padding: 0.5rem;
  border-radius: 9999px;
  background-color: var(--toggle-bg);
  cursor: pointer;
  transition: background-color 0.2s ease;
  align-self: flex-start;
  position: absolute;
  right: 20px;
}

.theme-toggle:hover {
  background-color: var(--toggle-hover);
}

.theme-toggle svg {
    transform-origin: center;
    transition: transform 0.3s ease;
}

.theme-toggle:active svg {
    transform: rotate(30deg);
}

h1 {
  display: flex;
}

.sun-icon {
  display: none;
  width: 24px;
  height: 24px;
}

.moon-icon {
  width: 24px;
  height: 24px;
}

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

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

Pro tip: Notice how we're using data-theme instead of classes? This makes it super clear what the attribute is for and prevents any potential class naming conflicts.

For the icons, you can use either your own SVGs or grab some from your favorite icon library. I like using simple ones so I told Chatgpt to come up with this:

<!-- Replace the empty SVGs with these -->
<svg class="sun-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
  <circle cx="12" cy="12" r="4"/>
  <path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32l1.41-1.41"/>
</svg>
<svg class="moon-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
  <path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
</svg>
Enter fullscreen mode Exit fullscreen mode

At this point, your toggle should look pretty decent, but it won't actually do anything yet. Don't worry though - in the next section, we'll add the JavaScript that makes it all work!

A quick heads-up: I've kept the styling minimal on purpose. Feel free to spice it up with your own creative touches. Want a sliding animation? Go for it! Prefer a different icon style? Make it yours!

Ready to make this thing actually work? Let's move on to the JavaScript implementation!

The JavaScript Implementation (Where It All Comes Together!)

Alright, this is where we make our toggle actually, you know... toggle. But don't worry - we're keeping it clean and simple.

const themeToggle = document.querySelector('.theme-toggle');


function toggleTheme() {
  const currentTheme = document.documentElement.getAttribute('data-theme');

  const newTheme = currentTheme === 'dark' ? 'light' : 'dark';

  document.documentElement.setAttribute('data-theme', newTheme);

  localStorage.setItem('theme', newTheme);
}

// Listen for clicks on our toggle
themeToggle.addEventListener('click', toggleTheme);


function initializeTheme() {
  const savedTheme = localStorage.getItem('theme');

  if (savedTheme) {
    document.documentElement.setAttribute('data-theme', savedTheme);
  } else {
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

    document.documentElement.setAttribute(
      'data-theme',
      prefersDark ? 'dark' : 'light'
    );

    localStorage.setItem('theme', prefersDark ? 'dark' : 'light');
  }
}

// Run on page load
initializeTheme();

// Listen for system theme change

window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    // Only update if user hasn't manually set a preference
    if (!localStorage.getItem('theme')) {
      document.documentElement.setAttribute(
        'data-theme',
        e.matches ? 'dark' : 'light'
      );
    }
  });
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening here because there's actually some pretty cool stuff going on:

  1. When someone clicks the toggle, we check the current theme and switch to the opposite one
  2. We save their choice in localStorage (so it persists between page loads)
  3. When the page first loads, we:
    • Check if they have a saved preference
    • If not, we check their system theme
    • Apply whichever theme is appropriate
  4. As a bonus, we listen for system theme changes (like when someone enables dark mode on their OS)

Preventing the Flash of Wrong Theme

Here's a common problem: sometimes users see a flash of the wrong theme when the page loads. Super annoying, right? Let's fix that by adding this script in the <head> of your HTML:

<script>
  // Add this to your <head> before any style sheets
  (function() {
    const savedTheme = localStorage.getItem('theme');
    if (savedTheme) {
      document.documentElement.setAttribute('data-theme', savedTheme);
    } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      document.documentElement.setAttribute('data-theme', 'dark');
    }
  })();
</script>
Enter fullscreen mode Exit fullscreen mode

This runs immediately before anything else loads, preventing that annoying flash.

And... that's it! You've got a working dark mode toggle that:

  • Remembers user preferences
  • Respects system settings
  • Doesn't flash the wrong theme
  • Works smoothly with transitions

Want to make it even better? Let's move on to some helpful tips that'll take your toggle from good to great!

Making it Better (Because Details Matter!)

Let's take our toggle from "it works" to "it works beautifully" with some important but often overlooked improvements. These are the kinds of details that separate a professional implementation from a quick hack.

1. Keyboard Accessibility

First up, let's make sure everyone can use our toggle, regardless of how they interact with their device:

// Add this to your existing JavaScript
themeToggle.addEventListener('keydown', (e) => {
    // Toggle on Enter or Space
    if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        toggleTheme();
    }
});
Enter fullscreen mode Exit fullscreen mode

2. Disabling transition

Disable transitions when user prefers reduced motion:

@media (prefers-reduced-motion: reduce) {
  body {
    transition: none;
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Handling Content Changes

Here's something many developers miss - some content might need to change based on the theme. Think about images with different versions for light/dark modes:

// Add this to your toggleTheme function
function updateThemeSpecificContent(theme) {
    // Find all theme-aware images
    const themeImages = document.querySelectorAll('[data-theme-image]');

    themeImages.forEach(img => {
        const lightSrc = img.getAttribute('data-light-src');
        const darkSrc = img.getAttribute('data-dark-src');

        img.src = theme === 'dark' ? darkSrc : lightSrc;
    });
}
Enter fullscreen mode Exit fullscreen mode

Use it in your HTML like this:

<img 
    data-theme-image
    data-light-src="/path/to/light-logo.png"
    data-dark-src="/path/to/dark-logo.png"
    alt="Logo"
>
Enter fullscreen mode Exit fullscreen mode

4. Preventing Theme Mismatch

Sometimes the saved theme can get out of sync with what's actually showing. Let's add a safety check:

// Add this to your initialization
function validateTheme() {
    const currentTheme = document.documentElement.getAttribute('data-theme');
    const savedTheme = localStorage.getItem('theme');

    if (currentTheme !== savedTheme) {
        // Something's wrong, reset to saved theme
        if (savedTheme) {
            document.documentElement.setAttribute('data-theme', savedTheme);
        } else {
            // If no saved theme, reset to system preference
            const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
            document.documentElement.setAttribute('data-theme', systemTheme);
            localStorage.setItem('theme', systemTheme);
        }
    }
}

// Run this periodically or after critical updates
setInterval(validateTheme, 1000);
Enter fullscreen mode Exit fullscreen mode

5. Performance Optimization Trick

Here's a neat trick to prevent layout shifts when loading custom fonts in different themes:

body {
    /* Prevent layout shift from font loading */
    font-display: optional;
}

/* Prevent content reflow when switching themes */
.content-wrapper {
    min-height: 100vh;
    contain: paint;
}
Enter fullscreen mode Exit fullscreen mode

Quick Testing Checklist

Before you ship, make sure to test these scenarios:

  • ✅ Page refresh maintains the correct theme
  • ✅ System theme changes are respected (if no manual preference)
  • ✅ Toggle works with both mouse and keyboard
  • ✅ No flashing of wrong theme on load
  • ✅ Transitions are smooth
  • ✅ Works in all major browsers (yes, even Safari!)
  • ✅ Theme-specific content updates correctly

Common Issues to Watch Out For

  1. Third-party Content: Some embedded content might not respect your theme. Handle it like this:
/* Force iframe background to match theme */
iframe {
    background-color: var(--background);
}
Enter fullscreen mode Exit fullscreen mode
  1. Images with Transparency: They can look wrong on different backgrounds:
/* Add a subtle background to transparent images */
img {
    background-color: var(--image-background, transparent);
}
Enter fullscreen mode Exit fullscreen mode

That's it! You now have a robust, accessible, and user-friendly dark mode implementation that handles different scenarios like a champ.

Conclusion

Well, there you have it! We've built a dark mode toggle that not only works but works really well.

Let's quickly recap what we've accomplished:

  • A toggle that actually remembers your users' preferences 🧠
  • Smooth transitions between themes without the dreaded flash ⚡
  • System preference detection that just works 🔄
  • Accessibility built-in from the start ♿
  • Performance optimizations to keep things snappy 🏃‍♂️

Where to Go From Here?

Feel free to take this code and make it your own. Maybe add some fancy animations, try out different color schemes, or integrate it with your existing design. The foundation we've built is solid enough to handle whatever creative ideas you throw at it.

One Last Thing...

Dark mode might seem like a small detail, but it's these little touches that show you care about your users' experience. Plus, it's just cool. Who doesn't love a good dark mode?

If you found this helpful, feel free to share it with other developers. And if you come up with any cool improvements, I'd love to hear about them!


Let's Stay Connected! 🤝

If you enjoyed this guide and want more web dev tips, hacks, and occasional dad jokes about programming, come hang out with me on X! I share quick tips, coding insights, and real-world solutions from my developer journey.

👉 Follow me @Peboydcoder

I regularly post about:

  • Web development tips & tricks
  • Frontend best practices
  • UI/UX insights
  • And yes, more dark mode appreciation posts 🌙

Drop by and say hi! Always excited to connect with fellow developers who care about building better web experiences.


Top comments (0)