Simple implementation of dark mode

Last week I was working on our new cockpit application, which is essentially a list of links to parts of our Wehkamp platform. The old application was not being maintained, as the React-stack is not something that's in the skill-set of most engineers. We kept the new cockpit simple: plain old HTML. Of course we wanted to support a nice dark-theme as well. This article shows how simple it is to implement dark mode.

Before image
After image
The light and dark themes of the new cockpit.

Getting a light bulb

It all starts with a light bulb that can toggle the dark mode on and off . I love the Flat Icon site, which has a ton of light bulb icons. We went with this light bulb, created by Good Ware, and added it as an icon like this:

<a href="#" onclick="toggleDarkMode()" id="toggleDarkMode" title="Toggle dark mode">
   <svg id="light" enable-background="new 0 0 24 24" height="20" viewBox="0 0 24 24" width="20" xmlns="http://www.w3.org/2000/svg"><g><path d="m13.5 24h-3c-.7 0-1.5-.6-1.5-1.8v-2.1c0-1-.5-1.9-1.3-2.6-1.8-1.4-2.7-3.4-2.7-5.6.1-3.8 3.2-6.8 6.9-6.9 1.9 0 3.7.7 5 2s2.1 3.1 2.1 5c0 2.1-.9 4.1-2.6 5.4-.9.7-1.4 1.8-1.4 2.8v2.3c0 .8-.7 1.5-1.5 1.5zm-1.5-18c-3.2 0-5.9 2.7-6 5.9 0 1.9.8 3.7 2.3 4.8 1.1.9 1.7 2.1 1.7 3.4v2.1c0 .2 0 .8.5.8h3c.3 0 .5-.2.5-.5v-2.3c0-1.3.7-2.7 1.8-3.6 1.4-1.1 2.2-2.8 2.2-4.6 0-1.6-.6-3.1-1.8-4.3-1.1-1.1-2.6-1.7-4.2-1.7z"/></g><g><path d="m14.5 21h-5c-.3 0-.5-.2-.5-.5s.2-.5.5-.5h5c.3 0 .5.2.5.5s-.2.5-.5.5z"/></g><g><path d="m12 3c-.3 0-.5-.2-.5-.5v-2c0-.3.2-.5.5-.5s.5.2.5.5v2c0 .3-.2.5-.5.5z"/></g><g><path d="m18.7 5.8c-.1 0-.3 0-.4-.1-.2-.2-.2-.5 0-.7l1.4-1.4c.2-.2.5-.2.7 0s.2.5 0 .7l-1.4 1.4s-.2.1-.3.1z"/></g><g><path d="m23.5 12.5h-2c-.3 0-.5-.2-.5-.5s.2-.5.5-.5h2c.3 0 .5.2.5.5s-.2.5-.5.5z"/></g><g><path d="m20.1 20.6c-.1 0-.3 0-.4-.1l-1.4-1.4c-.2-.2-.2-.5 0-.7s.5-.2.7 0l1.4 1.4c.2.2.2.5 0 .7 0 .1-.1.1-.3.1z"/></g><g><path d="m3.9 20.6c-.1 0-.3 0-.4-.1-.2-.2-.2-.5 0-.7l1.4-1.4c.2-.2.5-.2.7 0s.2.5 0 .7l-1.4 1.4c-.1.1-.2.1-.3.1z"/></g><g><path d="m2.5 12.5h-2c-.3 0-.5-.2-.5-.5s.2-.5.5-.5h2c.3 0 .5.2.5.5s-.2.5-.5.5z"/></g><g><path d="m5.3 5.8c-.1 0-.3 0-.4-.1l-1.4-1.5c-.2-.2-.2-.5 0-.7s.5-.2.7 0l1.4 1.4c.2.2.2.5 0 .7-.1.1-.2.2-.3.2z"/></g><g><path d="m16 12.5c-.3 0-.5-.2-.5-.5 0-1.9-1.6-3.5-3.5-3.5-.3 0-.5-.2-.5-.5s.2-.5.5-.5c2.5 0 4.5 2 4.5 4.5 0 .3-.2.5-.5.5z"/></g></svg>
</a>

We've added it as an SVG, so we can style it later on. Clicking the icon will trigger toggleDarkMode.

Toggle dark mode

We're going to implement dark mode as a class on the html element:

function toggleDarkMode() {
  let dark = document.getElementsByTagName("html")[0].classList.toggle("dark");
  document.cookie = "dark=" + dark;
}

Toggling dark mode is easy: just toggle the class. We also set cookie named dark, to keep the mode dark active over sessions.

CSS with variables

Let's implement dark mode using CSS custom properties:

:root {
  --header-shadow-color: rgba(85, 102, 119, 0.4);
  --background-color: #fff;
  --text-color: #000;
  --text-hover-color: #5c5c5c;
  --line-color: rgba(0, 0, 0, 0.1);
}

.dark:root {
  --header-shadow-color: #999;
  --background-color: #222;
  --text-color: #aaa;
  --line-color: #666;
}

It is that simple. Just add different colors when the dark class is active. One advantage of custom properties is that a property can be reused. Here we use --text-color for both color and fill (in case of an SVG):

a {
  text-decoration: none;
  color: var(--text-color);
  display: inline-block;
}

#toggleDarkMode svg {
  fill: var(--text-color);
}

a:hover {
  color: var(--text-hover-color);
  text-decoration: underline;
}

#toggleDarkMode:hover svg {
  fill: var(--text-hover-color);
}

Apply dark mode

The last thing we need to do is apply the dark mode on reload. Let's support 3 use cases:

  • The cookie dark is explicitly set to true or false.
  • The query string parameter dark is explicitly set to true or false (makes testing easier).
  • Or the user prefers dark theme with the media query prefers-color-scheme: dark.
(function () {
  const getCookieValue = (name) =>
    document.cookie.match("(^|;)\\s*" + name + "\\s*=\\s*([^;]+)")?.pop() || "";

  const getQueryStringValue = (name) =>
    new URLSearchParams(window.location.search).get(name);

  // do as fast as possible to prevent white snap
  // check explicit choice or user preference
  const dark = getQueryStringValue("dark") || getCookieValue("dark");
  if (
    dark == "true" ||
    (dark != "false" &&
      window.matchMedia &&
      window.matchMedia("(prefers-color-scheme: dark)").matches)
  ) {
    document.getElementsByTagName("html")[0].classList.add("dark");
  }
})();

Need something smaller?

!function(){const a=(a=>new URLSearchParams(window.location.search).get(a))("dark")||(a=>document.cookie.match("(^|;)\\s*"+a+"\\s*=\\s*([^;]+)")?.pop()||"")("dark");("true"==a||"false"!=a&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)&&document.getElementsByTagName("html")[0].classList.add("dark")}();

Make sure you load the script as soon as possible, preferably in the head element.

Skipping Cloudflare Rocker Loader

Need to skip the Rocket Loader? Add the data-cfasync=false to the script tag:

<script data-cfasync="false">
!function(){const a=(a=>new URLSearchParams(window.location.search).get(a))("dark")||(a=>document.cookie.match("(^|;)\\s*"+a+"\\s*=\\s*([^;]+)")?.pop()||"")("dark");("true"==a||"false"!=a&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)&&document.getElementsByTagName("html")[0].classList.add("dark")}();
</script>

For more info, check the documentation.

expand_less