Dark Mode Trickery

One addition to these pages you might have noticed is, at the very bottom, an icon allowing you to switch between dark and light mode. And it’s not just a simple switch, it’s a tri-switch! While it allows for fixed light and dark modes, it also includes an automatic mode (i.e., based on your system settings).

And yes, there are quite a few ways that smarter people have used, but neither one worked exactly how I wanted. So, let’s see yet another way to do the same thing.

First step is, of course, setting up CSS. All colors for light scheme get to be defined in section with prefers-color-scheme: light, while dark colors get their prefers-color-scheme: dark section. I personally like to use these to setup variables to be used later, but you can define styles directly too.

@media (prefers-color-scheme: light) {
  /* styles */
}
@media (prefers-color-scheme: dark) {
  /* styles */
}

Next step is setting up a “button” for switching between themes. While we define three links, neither one of them is shown by default - we’ll sort that out later in the code.

<span>
  <a href="" id="color-scheme-auto" style="display: none;">Auto</a>
  <a href="" id="color-scheme-dark" style="display: none;">Dark</a>
  <a href="" id="color-scheme-light" style="display: none;">Light</a>
</span>

And yes, for my pages I don’t actually use text but icons. Below are links for Lucide icons I use currently, but you can go with whichever set you want.

  •  (automatic)
  •  (dark)
  •  (light)

Lastly, we come to the code and I’m just gonna drop the whole thing here. Explanations as to what each section does will be below.

<script>
  let systemScheme = 'light';
  if (window.matchMedia('(prefers-color-scheme: light)').matches) { systemScheme = 'light'; }
  if (window.matchMedia('(prefers-color-scheme: dark)').matches) { systemScheme = 'dark'; }

  let savedScheme = localStorage.getItem("color-scheme");
  let currentScheme = systemScheme;
  switch (savedScheme) {
    case "light":
      currentScheme = "light";
      break;
    case "dark":
      currentScheme = "dark";
      break;
    default:
      savedScheme = "auto";
      break;
  }

  if (currentScheme !== systemScheme) { // swap at start so there's no flash
    for (var s = 0; s < document.styleSheets.length; s++) {
      try {
        for (var i = 0; i < document.styleSheets[s].cssRules.length; i++) {
          rule = document.styleSheets[s].cssRules[i];
          if (rule && rule.media && rule.media.mediaText.includes("prefers-color-scheme")) {
            ruleMedia = rule.media.mediaText;
            if (ruleMedia.includes("light")) {
              newRuleMedia = ruleMedia.replace("light", "dark");
            } else if (ruleMedia.includes("dark")) {
              newRuleMedia = ruleMedia.replace("dark", "light");
            }
            if (newRuleMedia !== null) {
              rule.media.deleteMedium(ruleMedia);
              rule.media.appendMedium(newRuleMedia);
            }
          }
        }
      } catch (e) { }
    }
  }

  function nextColorScheme() {
    switch (savedScheme) {
      case "light": localStorage.removeItem("color-scheme"); break;
      case "dark": localStorage.setItem("color-scheme", "light"); break;
      default: localStorage.setItem("color-scheme", "dark"); break;
    }
    window.location.reload();  // to force button update
  }

  function updateButtons() {
    switch (savedScheme) {
      case "light": document.getElementById("color-scheme-light").style.display = 'inline'; break;
      case "dark": document.getElementById("color-scheme-dark").style.display = 'inline'; break;
      default: document.getElementById("color-scheme-auto").style.display = 'inline'; break;
    }
  }

  document.addEventListener('DOMContentLoaded', function() {
    document.getElementById('color-scheme-auto').addEventListener('click', nextColorScheme);
    document.getElementById('color-scheme-dark').addEventListener('click', nextColorScheme);
    document.getElementById('color-scheme-light').addEventListener('click', nextColorScheme);
    updateButtons();
  });
</script>

The first portion just determines system scheme and stores it in systemScheme variable. Variable will contain whatever system tells the preferred scheme should be - either light or dark.

Next portion is all about loading what user (maybe) saved the last time. For this purpose we’re using localStorage and the result gets stored in savedScheme variable. Its state will set currentScheme variable to match either what is stored or the system scheme if we have no better idea (i.e., automatic mode).

End result of this variable game is decision if currentScheme differs from systemScheme. If they are different, we simply swap dark and light settings around. This swap is actually what does all the heavy lifting.

The nextColorScheme method checks the current state (savedState variable) and moves to the next one. States are written as light and dark. For automatic handling, code simply deletes storage altogether. Once that is done, it won’t attempt to sort out any swaps needed to get colors in line. Nope, it will simply reload the page and let the loading code sort it out.

The updateButtons is what displays whatever scheme is selected for a bit of user feedback.

The last portion of code will add an event listener to the click event of each scheme “button” (identified by id) so that each click calls nextColorScheme method. Here is also where we call updateButtons method to show the current state.

With all this in place, our theme switching should just work.