Simple implementation of dark mode

KeesTalksTech finally has a dark theme! It was one of those items that had been on my wishlist for years. Last week I've implemented the new Mona Sans font by GitHub, so it felt natural to see if I could create a dark mode for my blog.

In a previous version of this article I've used cookies and a class to activate dark mode, in this new article I'll focus on just a dark stylesheet that's optionally activated or not. It makes maintenance of the styles way easier, as there are no classes involved.

Before image
After image
The light and dark themes of KeesTalksTech

A simple stylesheet for darkness..

My dark theme is super easy. Why? My site already uses CSS variables for its colors. My main challenge was the SVG logo: how do I change its colors? Instead of including the logo as an image, I have now included it as an SVG element in the DOM, so I can tweak the coloring of the elements with a stylesheet.

My dark.css stylesheet looks like this:

:root {
  --background-color: #161B22;
  --foreground-color: #ddd;
  --secondary-color: #B0B0B0;
  --soft-color: #333;
  --light-background: #282C34;
  --footer-background: #111;
}

#site-title svg path[data-label],
#site-title svg path[fill="#fff"]{
    fill: var(--foreground-color);
}

To make it work "out-of-the-box", I only need to include it in the <head> after my main styles:

<link rel="stylesheet" id="ktt-style-dark" href="dark.css" media="(prefers-color-scheme: dark)" />

When a user that has the preference for dark mode, she will get it.

But I want to force a mode...

Now, here is where it gets tricky, because now we have to code. Let's first distinquish 3 modes: auto (based on user preference), light (selected), dark (selected). We just got the auto version to work with the media property of the stylesheet.

Now we need to do the following:

  • When the user selects light, we need to disable the dark stylesheet. My default theme is light and the user will get a light experience even if the preference of the browser is set to dark.
  • When the user selects dark, we need to enable the dark stylesheet and remove the media attribute. The sheet now takes precedence over my default light theme variables.
  • The user preference can be stored in local storage and should be applied every time we load a new page.

Add a dark script

We're going to add some scripting to:

  • Change the mode to dark, light or auto
  • Load the previous mode on a new page

This script should also be loaded in the head, right after the dark stylesheet, so it is executed directly when the page is loaded. This will prevent flashes between page navigations.

<link rel="stylesheet" id="ktt-style-dark" href="dark.css" media="(prefers-color-scheme: dark)" />
<script data-cfasync="false" src="dark.min.js"></script>

The data-cfasync="false" will prevent Cloudflare from optimizing the script. For more info, check the documentation.

The button

Let's build a button that uses Material Icons to display the current state: brightness_auto (brightness_auto), dark_mode (dark_mode) and light_mode (light_mode). This button had ID dark-mode-toggle in my setup. The name of the icon will serve as state.

<a id="dark-mode-toggle" href="#" onclick="toggleMode(); return false;">brightness_auto</a>

Toggle

Clicking the button will toggle the state. We'll add a data-initial-choice on the button, so we can determine the preference when the page loaded (we'll implement it later). Let's make it intuitive for the end-user, so when the button is clicked, something should always be happening. The trick is to get the brightness_auto in the right position of "next states".

function toggleMode() {
  const button = document.getElementById("dark-mode-toggle");
  const initialChoice = button.getAttribute("data-initial-choice");
  const prefersDarkMode = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;

  let modes =
    initialChoice == "light_mode" || (!initialChoice && prefersDarkMode)
      ? ["light_mode", "dark_mode", "brightness_auto"]
      : ["dark_mode", "light_mode", "brightness_auto"];

  const currentMode = button.innerText;
  const nextMode = modes[(modes.indexOf(currentMode) + 1) % modes.length];
  setMode(nextMode);

  const modeChangedEvent = new CustomEvent("themeChanged", {
    detail: { previousMode: currentMode, mode: nextMode, prefersDarkMode }
  });

  document.dispatchEvent(modeChangedEvent);
}

Now, we only need a function that applies the mode:

function setMode(mode) {
  localStorage.setItem("dark-mode-toggle", mode);
  let sheet = document.getElementById("ktt-style-dark");

  switch (mode) {
    case "brightness_auto":
      sheet.setAttribute("media", "(prefers-color-scheme: dark)");
      sheet.disabled = false;
      break;
    case "dark_mode":
      sheet.disabled = false;
      sheet.removeAttribute("media");
      break;
    case "light_mode":
      sheet.disabled = true;
      break;
  }

  const button = document.getElementById("dark-mode-toggle");
  if (button) {
    button.innerText = mode;
  }
}

Restore choice upon page load

There is only one chore left, and that's restoring the user choice for local storage upon page load:

(function () {
  const choice = localStorage.getItem("dark-mode-toggle");

  // no choice? just let the defaults be
  if (!choice || choice == "brightness_auto") {
    return;
  }

  setMode(choice);

  document.addEventListener("DOMContentLoaded", () => {
    const button = document.getElementById("dark-mode-toggle");
    button.setAttribute("data-initial-choice", choice);
    button.text = choice;
  });
})();

Putting it all together

When we put it together, and we do some further refactoring we'll get this script:

(function (window, document, localStorage) {
  const AUTO = "brightness_auto",
    LIGHT = "light_mode",
    DARK = "dark_mode",
    LOCAL_STORAGE_KEY = "dark-mode-toggle",
    TOGGLE_BUTTON_ID = "dark-mode-toggle",
    INITIAL_CHOICE_ATTRIBUTE = "data-initial-choice";

  function setMode(mode) {
    localStorage.setItem(LOCAL_STORAGE_KEY, mode);
    let sheet = document.getElementById("ktt-style-dark");

    switch (mode) {
      case AUTO:
        sheet.setAttribute("media", "(prefers-color-scheme: dark)");
        sheet.disabled = false;
        break;
      case DARK:
        sheet.disabled = false;
        sheet.removeAttribute("media");
        break;
      case LIGHT:
        sheet.disabled = true;
        break;
    }

    const button = document.getElementById(TOGGLE_BUTTON_ID);
    if (button) {
      button.innerText = mode;
    }
  }

  window.toggleMode = function () {
    const button = document.getElementById(TOGGLE_BUTTON_ID);
    const initialChoice = button.getAttribute(INITIAL_CHOICE_ATTRIBUTE);
    const prefersDarkMode = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;

    let modes =
      initialChoice == LIGHT || (!initialChoice && prefersDarkMode) ? [LIGHT, DARK, AUTO] : [DARK, LIGHT, AUTO];

    const currentMode = button.innerText;
    const nextMode = modes[(modes.indexOf(currentMode) + 1) % modes.length];
    setMode(nextMode);

    const modeChangedEvent = new CustomEvent("themeChanged", {
      detail: { previousMode: currentMode, mode: nextMode, prefersDarkMode }
    });

    document.dispatchEvent(modeChangedEvent);
  };

  const choice = localStorage.getItem(LOCAL_STORAGE_KEY);

  // no choice? just let the defaults be
  if (!choice || choice == AUTO) {
    return;
  }

  // restore previous choice
  setMode(choice);

  // set right text to the button after the DOM has loaded
  document.addEventListener("DOMContentLoaded", () => {
    const button = document.getElementById(TOGGLE_BUTTON_ID);
    button.setAttribute(INITIAL_CHOICE_ATTRIBUTE, choice);
    button.text = choice;
  });
})(window, document, localStorage);

Further reading

While working on this topic, I found some excellent sources for reading:

Changelog

  • added Iconify.design.
  • fixed the toggleMode function. The markup was eaten up by the blog.
  • added accessilbility
  • added the further reading section.
  • implemented the themeChanged event.
  • rewrite with KeesTaksTech as use-case.
  • initial article.

expand_less brightness_auto