Building a dark mode toggle switch

Despite feature such as prefers-color-scheme, building a dark mode toggle switch can be a pain. There's a number of ways that the user can change their preferred theme and we need to handle all those cases.

  1. The actual press on the toggle button
  2. The switch of the default preferred theme
  3. The theme if the user chooses not to load JavaScript

Respecting the user's preferred color scheme in CSS

Easiest method but only works if the user agent prefers a color scheme. In Firefox this can be set through the ui.systemUsesDarkTheme option in about:config or set by a theme. Both Firefox and Chrome should also inherit the preference from the OS. Both browser's devtools also allow temporary configuration of the preferred color scheme for testing.

@media (prefers-color-scheme: light) {
    :root {
        background-color: #fdf6e3;
        color: black;
    }
}

@media (prefers-color-scheme: dark) {
    :root {
        background-color: #1c1c1c;
        color: #cccccc;
    }
}

Simple and straightforward, the media query handles the rule change and we just need to design the colors to our preference.

Creating toggleable CSS rules

Now we need a way for our CSS rules to toggle between light and dark modes. The easiest way is to add a data attribute that we can query in CSS to override the media query. We have to duplicate all the existing properties but it's the only way to ensure that the correct theme is displayed.

/* dark theme override browser preference */
:root[data-color-scheme="dark"] {
    background-color: #1c1c1c;
    color: #cccccc;
}

:root[data-color-scheme="dark"] a:link {
    color: #a3b6ff;
}

The selector specificity here allows these rules to override those similar selectors in the media query and the descendent combinator is used to target other elements that we wish to style. With these rules in place all we need to do is add a button that adds our data-color-scheme attribute to the :root element.

Adding our switch

My switch consists of a sun and moon icon that interchange by setting display: none.

<div class="color-scheme-toggle display-none" aria-hidden="true">
   <span class="sun-icon">&#9728;&#65039;</span>
   <span class="moon-icon">&#127769;</span>
</div>

The parent div initially has the display-none utility class which prevents it from showing until JS is ready. A keen observer will note that I set aria-hidden="true". I have no idea if that was the right decision or not. Do people that use screen readers also have a preference between light and dark modes?

Now the JavaScript

There are a few edge cases to handle but the code is just a few event listeners.

export function darkMode() {
   const colorSchemeToggle = document.querySelector(".color-scheme-toggle");
   colorSchemeToggle.classList.remove("display-none");
   const moonIcon = colorSchemeToggle.querySelector(".moon-icon");
   const sunIcon = colorSchemeToggle.querySelector(".sun-icon");

   moonIcon.addEventListener("click", () => {
      moonIcon.classList.add("display-none");
      sunIcon.classList.remove("display-none");
      document.documentElement.setAttribute("data-color-scheme", "light");
   });

   sunIcon.addEventListener("click", () => {
      sunIcon.classList.add("display-none");
      moonIcon.classList.remove("display-none");
      document.documentElement.setAttribute("data-color-scheme", "dark");
   });

   const darkMode = window.matchMedia('(prefers-color-scheme: dark)');
   const lightMode = window.matchMedia('(prefers-color-scheme: light)');

   if(darkMode.matches) {
      sunIcon.classList.add("display-none");
      document.documentElement.setAttribute("data-color-scheme", "dark");
   }
   else if(lightMode.matches) {
      moonIcon.classList.add("display-none");
      document.documentElement.setAttribute("data-color-scheme", "light");
   }

   darkMode.addEventListener("change", e => {
      if(e.matches) {
         document.documentElement.setAttribute("data-color-scheme", "dark");
         sunIcon.classList.add("display-none");
         moonIcon.classList.remove("display-none");
      }
   });

   lightMode.addEventListener("change", e => {
      if(e.matches) {
         document.documentElement.setAttribute("data-color-scheme", "light");
         moonIcon.classList.add("display-none");
         sunIcon.classList.remove("display-none");
      }
   });
}

We start by un-hiding the toggle switch because we know that our JS has loaded. Next all we have to do is listen for clicks, the default preferred color scheme, and the user changing their default.

Done! But there are a lot of possible improvements:

Conclusion

This was a complete waste of time and effort for a feature like dark mode. I guess there's value in switchable themes but for a well-designed application is it really important that the end-user can switch themes at will? The amount of code we have to write just explodes with duplication and state handling compared to the CSS-only solution, and it's becoming easier for users to opt-into dark mode via the OS or browser setting.