← Read More

Way too much about dark modes

Published on 2021-03-17

I recently switched from SquareSpace to coding my own blog, which you can read about here. One of the main reasons was so that I could add new cool stuff to my site; like a dark mode. I am surrounded by dark mode fanatics. I am not as die hard as some people I've met but I do like them and dark modes can be an important accessibility feature since they make your website easier on the eyes.

This was originally going to be the first in my series of shorter posts but implementing a good dark mode turned out to be harder than I thought. I didn't just want to have any dark mode, I had some very specific goals in mind:

  • Use the device's default dark mode preference
  • Work at least partially without JavaScript
  • Allow the user to set their own custom preference
  • Remember the user's preference when they come back to the site

anchor link Dark Modes in CSS

Recently, Safari introduced a browser-wide dark mode as part of Apple's new system-wide dark mode feature. This dark mode preference is made available to web pages via a media query. For a detailed explanation, check out this article. The adoption across the web is pretty good already, even though it is just a draft right now. I love this feature because it makes for a great user experience to set your preference once and have it respected on every web page and app you use while giving web pages and apps the freedom to customize their themes. However, since it is still not fully supported it will also be important to make sure to have a manual setting for the user. The most basic way to use this feature to do your dark mode is:

@media (prefers-color-scheme: dark) {
  body {
    color: white;
    background-color: dimgrey;
  }
}

This will detect the user's preference and change the background color and text color if a preference for dark mode is detected. Not only does this respect the user's preference but it takes effect immediately and works without JavaScript. This example doesn't use a great CSS strategy though. You may want more complicated color changes across your website when you change color schemes. Also, you may accidentally override these styles elsewhere in your CSS. To address this I used CSS variables. I like to use these even without a dark mode since I generally have a few consistent styles that I want applied across my website. This way, if I want to color some text I can use var(--text-color) and my text will automatically be the correct text color I've chosen for my theme. CSS variables don't have perfect support yet either so you may want to include a fallback value but if a browser doesn't support them it almost certainly doesn't support prefers-color-scheme anyway. So the above example with variables may look something like:

/* Set the variables to their light mode values */
:root {
  --text-color: black;
  --background-color: white;
}

/* If the user's preferred color scheme is dark */
@media (prefers-color-scheme: dark) {
  /* Override the variables with their dark mode values */
  :root {
    --text-color: white;
    --background-color: dimgrey;
  }
}

body {
  /* Define properties up here if you want fallback values */
  color: black;
  background-color: white;

  /* If CSS variables are supported these will override the default values */
  color: var(--text-color);
  background-color: var(--background-color);
}

This looks a little clunkier in the tiny example but it makes dealing with bigger projects a lot easier. You can define as many properties as you want and use them in any stylesheet now. This CSS approach is pretty quick to do and will work for most of your users so this is not a bad place to stop if you want to.

anchor link Overriding the System

Some users may not have devices or browsers that support prefers-color-scheme or they may set a system preference for one mode but want to read the site in a different mode. Creating a button to override the current default solves both of these cases. It's also pretty cool. As far as I am aware there is no way to do this without JavaScript though.

We need to update our website whenever the user changes their preference for dark mode. There are a lot of ways people do this, for example, some people use CSS classes. I felt the easiest way to do this was to define both color options as CSS variables with dark- or light- in front of them, then have a third variable for each color with theme- in front to store the current theme's value. This makes it easy to update our CSS variables with JavaScript but still gives us access to the values in CSS so our pure CSS dark mode still works. The variables make it obvious that you want to use the theme color and you want that color to change with the theme. To do this, first declare all of your CSS variables:

:root {
  /* Define the light mode values */
  --light-text-color: black;
  --light-background-color: white;

  /* Define the dark mode values */
  --dark-text-color: white;
  --dark-background-color: dimgrey;

  /* Set the theme variables to the light variable values by default */
  /* These are the values the site is actually going to use */
  --theme-text-color: var(--light-text-color);
  --theme-background-color: var(--light-background-color);
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Set the theme variables to the dark mode values */
    --theme-text-color: var(--dark-text-color);
    --theme-background-color: var(--dark-background-color);
  }
}

Now we need to create a JavaScript function to change them:

// We'll need this query more than once now so let's save it
// darkQuery.media updates when the media changes so can
// re-use this variable without worrying about it getting stale
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");

// We'll also need to convert our query to a theme a few times
// I am not referencing darkQuery here because I will call
// this on another query object later
function toTheme(q) {
  return q.matches ? "dark" : "light";
}

// Get our --theme variables
// modified from: https://codepen.io/tylergaw/pen/jObmNNM
const themeVariables = [];
for (let sheet of document.styleSheets) {
  // If the style sheet is from the same domain
  if (!sheet.href || sheet.href.indexOf(window.location.origin) === 0) {
    for (let rule of sheet.cssRules) {
      // If tye rule is a style rule
      if (rule.type === 1) {
        for (let style of rule.style) {
          const name = style.trim();
          if (name.indexOf("--theme") === 0) themeVariables.push(name);
        }
      }
    }
  }
}

// Create an empty __onThemeChange function
// We can replace this with a different function somewhere
// else on the site if we want that function to be called
// whenever the theme changes
window.__onThemeChange = function() {};

window.__setTheme = function(newTheme) {
  // Store the theme in a global variable so we can access the
  // current theme anywhere on the site
  window.__theme = newTheme;

  // Call our __onThemeChange function to notify any listeners
  window.__onThemeChange(newTheme);

  // Get the user's system color scheme
  const systemColorScheme = toTheme(darkQuery);

  // For each of our theme variables
  themeVariables.forEach(function (themeVariable) {
    // If the new theme is null, undefined, or the same as the system's theme
    if (!newTheme || newTheme === systemColorScheme) {
      // Remove our overriding variables to fall back to the CSS behavior
      document.documentElement.style.removeProperty(themeVariable);
    } else {
      // Set the current theme's variable to the new theme's variable
      // ex. --theme-text-color: var(--light-text-color) becomes:
      // --theme-text-color: var(--dark-text-color)
      const newValue = `var(${themeVariable.replace("theme", newTheme)})`;

      // Add our new variable setting to the document
      document.documentElement.style.setProperty(themeVariable, newValue);
    }
  });
}

// If the user changes their system theme while they're on the site we
// assume they want to change the theme so we update it
darkQuery.addListener(function(e) {
  // Here we're using toTheme on e, this is why we didn't
  // reference darkQuery earlier when we defined it
  window.__setTheme(toTheme(e));
});

Now you can call this function from whatever code you use to update your theme. I am not going to go too deeply into making the button because then this would be way way too much about dark modes. Also, my button is very Svelte-ey so this post would end up too Svelte specific.

If your website doesn't reload the page when you navigate this theme will persist across pages. This will be the case for all of your classic Single Page Applications like your Reacts, your Angulars, your Vues, your Sveltes, ect... This should also work if you're using one of these with a Server Side Rendering framework like NextJS, Gatsby, or Sapper. Since I am using Svelte + Sapper this approach works for me between pages, but if your site does reload the page for navigation, you'll need to remember their setting just to persist it across page navigation so read on to learn how.

anchor link Remembering

If one of my visitors specifies that they want a certain color mode I don't want them to need to re-set it every time they come back to my site or refresh the page. The method for dealing with this is the same method for making the theme persist across page navigation for a site that reloads on navigation. To remember the user's preference we need to save it in local storage. This way it will live on the user's machine and our site can read the user's preferred theme and use it when they return. We can do this with just a few small tweaks to our JavaScript:

const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
function toTheme(q) {
  return q.matches ? "dark" : "light";
}

const themeVariables = [];
for (let sheet of document.styleSheets) {
  if (!sheet.href || sheet.href.indexOf(window.location.origin) === 0) {
    for (let rule of sheet.cssRules) {
      if (rule.type === 1) {
        for (let style of rule.style) {
          const name = style.trim();
          if (name.indexOf("--theme") === 0) themeVariables.push(name);
        }
      }
    }
  }
}

window.__onThemeChange = function() {};

// Make setTheme not a global function anymore (not a property of window)
// This function does the work of applying a theme, we need to call
// this alone to apply our stored theme initially, but everywhere else
// we want to both set the theme in storage and apply it so we
// don't want to be able to call this elsewhere on the site
function setTheme(newTheme) {
  window.__theme = newTheme;
  window.__onThemeChange(newTheme);
  const systemColorScheme = toTheme(darkQuery);
  themeVariables.forEach(function (themeVariable) {
    if (!newTheme || newTheme === systemColorScheme) {
      document.documentElement.style.removeProperty(themeVariable);
    } else {
      const newValue = `var(${themeVariable.replace("theme", newTheme)})`;
      document.documentElement.style.setProperty(themeVariable, newValue);
    }
  });
}

try {
  // Grab the theme from local storage and use it to
  // initialize the preferred theme. If we can't find
  // it this will be null so setTheme will leave
  // the default CSS behavior. If local storage doesn't
  // work for whatever reason this will throw an error
  // which is caught and ignored and the default CSS
  // behavior will remain in place
  const storageTheme = localStorage.getItem("theme");

  // If our theme is not one of the supported values for
  // whatever reason
  if (storageTheme !== "dark" && storageTheme !== "light") {
    // Remove the bad value (what can go wrong will go wrong...)
    localStorage.removeItem("theme");
  } else {
    // Apply the preferred theme
    setTheme(storageTheme);
  }
} catch (err) { }

window.__setPreferredTheme = function(newTheme) {
  // Apply the new theme
  setTheme(newTheme);
  try {
    // Make sure the theme is one of our supported themes
    // This also ensures we don't store null or undefined.
    // Local Storage will actually convert null or undefined
    // to a string so saving it would wreak havoc. When the
    // values are saved they are falsey but when they are
    // read they are truthy so it can break some logic
    if (newTheme === "dark" || newTheme === "light") {
      // Set the theme in local storage. Just like before
      // we use a try/catch to ignore any errors from local storage
      localStorage.setItem("theme", newTheme);
    }
  } catch (err) {}
}

darkQuery.addListener(function(e) {
  // use __setPreferredTheme instead of __setTheme here
  window.__setPreferredTheme(toTheme(e));
});

anchor link CSS Bonus Points: Transitions

Changing the theme all at once can be a bit jarring. The change will look a bit nicer if you use some CSS transitions. You can add some CSS transitions wherever you use the theme variables. I am using a transition-time variable so the transition will be uniform across the site. I chose .2s for my transition time. Notice how transition-time does not have the --theme prefix. This is intentional because this variable does not change between light mode and dark mode. Here is an example of transitions on <body>:

:root {
  --transition-time: .2s;
}

body {
    color: var(--theme-color);
    background-color: var(--theme-background-color);

    -webkit-transition: color var(--transition-time);
    -moz-transition: color var(--transition-time);
    -o-transition: color var(--transition-time);
    transition: color var(--transition-time);

    -webkit-transition: background-color var(--transition-time);
    -moz-transition: background-color var(--transition-time);
    -o-transition: background-color var(--transition-time);
    transition: background-color var(--transition-time);
}

It's a little annoying to add this whenever you use a theme variable but as far as I can tell this is the best solution with my current setup. At first I put some transitions on * but on Chrome that sometimes caused a weird double transition. Also, you don't know what properties you may want to change without a transition or what properties you may want to add to your theme later so you can't really set it and forget it. For example, I set the global transition on background-color and color and one of my border-colors wasn't transitioning. This is the part I am least satisfied with so far so I will keep an eye out for something better and update it if I find something. I think if I went with a more sophisticated CSS solution (something like SASS) there would be a better solution for this but I don't think this is bad enough to warrant picking one and setting it up now.

So I had the dark mode implemented and I even had the blog post mostly written, but something was haunting me. If the user set their dark mode preference to be different than their device's and returned to the site they would see the wrong theme for an instant before it blinked to the right one. I was distraught; I can't stand UI blinks. A major reason I switched to hosting my own site is that my code's syntax highlighting blinked before loading and this dark mode thing was even worse. I looked at another web page I knew had a configurable dark mode, the docker docs and I noticed that on Firefox, at least sometimes, the page briefly flashes white on a page refresh if you have the dark mode configured. This was bad news. I started to think that if you have a static site there is just nothing you can do to avoid the blink in some cases. I thought that maybe you would need to use a cookie and render the dark mode or light mode on the server side to avoid it.

But then, a glimmer of hope. I was on Dan Abramov's blog for unrelated reasons and I noticed that he had a configurable dark mode option. I refreshed the page and there was no blink. I inspected the network traffic, no cookie; he can't be doing this server side. I checked page's local storage and he had the theme stored there, just like I did. But how did he do it? He had no blog post on it so I inspected the source of his page and I found the secret that had been eluding me. I completely changed my approach and I re-wrote the top half of this blog post because of it. I ended up changing a lot from his original approach but it gave me the core of what I needed.

What I found was that the very first element in the <body> section of his HTML was an inline <script> with the logic for setting the dark mode. The problem with my approach is that I was running the JavaScript that updated the theme in a Svelte <script> tag and that code doesn't run soon enough. The components will be visible before that code runs. This problem isn't specific to Svelte, lots of frameworks will have the same issue. Dan Abramov is using React + Gatsby and he also had to explicitly handle the dark mode separately from the rest of his code. The key is the script tag with the code that initializes your dark mode must be before your site's elements. However, this really only matters if you are doing Server Side Rendering because otherwise you can just wait to render until you read the theme from local storage.

Before you Svelte/Sapper experts try to correct me, I know all of Sapper's scripts are in the <head> tag, so they are before any element. Svelte scripts must be explicitly loaded later. This actually makes sense because scripts before your site's elements will be run before your site's elements are visible. For small stuff there is no way that will be noticeable but if you had a big script in there it could slow things down. The whole point of Sapper is serving a fully rendered page then doing the JavaScript stuff later for a super fast and responsive site so this is definitely the right decision on Sapper's part.

There is one more issue to putting your dark mode logic before any of your elements. You need to apply the dark mode in a way that doesn't require the elements that appear after your <script> tag. So if you do what I did and add the script in <head> you couldn't do something like this:

document.body.classList.add("new-class");

Because the body doesn't exist yet. In Dan's approach he does do something like this because he put his logic under the opening <body> so he can reference the <body> itself but he couldn't reference any elements within <body>.

anchor link But Wait, There's More

So with way more effort than I originally thought I met all of my goals and got a dark mode I am really happy with. I also learned a lot along the way, which was really the point of this whole exercise. There was actually more that went into making this dark mode that didn't make it into the post due to length. For example:

  • Making that sweet fading button
  • Transitioning between two different syntax highlighting themes
  • Syncing the dark mode stuff with Svelte so I can work with it in my components

Some of these may make future mini (actually mini 🤞) posts so stay tuned.

← Read More