Building & Breaking Web Applications

Published on

I'm happy to report that dark mode is now live on this site. While a small feature, it's an important one that I wanted to setup before I got too deep into writing articles and making more publishing-specific feature improvements.

Turns out it was a small feature, but not as quick of one as I anticipated*, because I ended up having to mix-and-match ideas from several sources in order to create a solid implementation that worked well with Nuxt 3. With that in mind I'm sharing my approach as I didn't quite find what I was looking for out on the web already.

* I did make this intentionally harder on myself, building it from scratch. Nuxt has a color-mode module that could be added more quickly. I chose to roll my own approach for learning's sake and to have one fewer dependency to worry about.

We're going to dive into the technical details of that in a moment, but first a quick detour.

Why dark mode?

To be cool and fit in with the dark mode loving cybersecurity community, of course, that's the only reason I need!

I'm half joking, the aesthetics of dark themes are excellent (when done well). Plus putting the time into building the palette for a dark theme was an excuse for me to put some deeper thought into the color palette of the site overall, helping me also improve the pre-existing light theme in the process.

More to the point though, having a dark mode is an important accessibility feature to have. Having a choice between light and dark gives your reader the ability to adapt your site to the setting that works better for their specific experience—and adjust it if their context changes, like switching from a bright office-like environment to the deep darkness of an editor's cave.

Getting deep into the considerations of how or why to have a dark mode option would make this piece too long. For some additional reading check the footnotes. With that, let's move on to the technical part.

Implementing dark mode in Nuxt 3

The first step for dark mode, regardless of what you're building the site with, is to define your color scheme in CSS. The most maintainable current method is using CSS variables with variable overwriting when the theme changes.

To define the color palette my overall approach was to have a weight-based color scale, built with the tool Accessible Palette (highly recommended!). With this setup I inverted the weight values for each color: e.g., the value of the --p-color-900 variable (the darkest shade) in light mode is assigned to --p-color-100 in dark mode. This worked pretty well out of the box across the site as a baseline. You'll likely need, as I did, some case-by-case adjustments to individual elements to preserve good contrast ratios—in many cases my dark theme needed to have the lighter, more desaturated, values in order to feel readable.

For this site I designed it so that there was the light mode as the default, with the variables set on the :root element in CSS, and used the same variable names for a separate .dark class which overwrites the root variables when it is active. Here's a quick snippet of how this works in practice:

:root {
  /* other colors on the scale */
  --p-color-900: #510c92;
  --s-color-100: #dff2f0;
}
.dark {
  --p-color-900: #f2ebf7;
  --s-color-100: #05413c;
}
/* Implementing the variables on elements */
html {
  background-color: var(--b-color-0);
  color: var(--font-color-900);
  transition: color 0.5s, background-color 0.5s;
}

That's already close to everything, but in my case with the Nuxt Content module it uses a built in code highlighting plugin, Shikiji, which uses its own color theme that and supports color modes. Adding a color mode to it is a simple change to its nuxt.config.ts section:

content: {
        highlight: {
            theme: {
                default: 'github-light',
                dark: 'github-dark',
            }
        }
},

Shikiji is why I chose to use a class for the CSS color variables, as Shikiji handles this by adding a .dark class to the html element to switch its theme. If you don't have that restriction, applying the theme as an HTML attribute like [data-theme="dark"] requires fewer lines of code to switch between modes.

Now so long as you're consistently using the color variables throughout the CSS, as shown with the HTML element earlier then you're already close to having a basic light/dark mode working. If you manually add the dark class to the html element (body can also work) the theme will switch, but as is that can only be done manually. Enter Javascript.

Building the toggle

HTML doesn't change itself, so the next necessary step for the basic dark/light toggle is to build some kind of button that can change the HTML and trigger the CSS change. As we're using Vue we make use of its event bindings inside a new component, ToggleTheme.vue. The standard way to make a toggle element, because right now in HTML there isn't an actual "toggle" input element, would be to use <input type="checkbox" /> with code similar to the how W3Schools implements it. But we're not going to do that and instead we'll use a button element, mimicking how the Vue docs site creates the toggle slider element.

Why? Well, the checkbox can work, but in my testing because the toggle's visual state was dependent on the checkbox's checked value, that would get lost on reload. Not the end of the world, we could change that state on load, but the button was a simpler implementation as it could update its position based solely on whether the .dark class was set or not, without any need for an onMounted Javascript call each time a page with the toggle loaded.

But enough talking, here's the code:

// ThemeToggle.vue
<script setup lang="ts">
const toggleTheme = () => {
  const rootElem = document.documentElement;
  const currentTheme = localStorage.getItem("theme");
  if (currentTheme == "light") {
    // using [data-theme] would be set with rootElem.setAttribute("data-theme", "dark");
    rootElem.classList.add("dark");
    rootElem.classList.remove("light");
    localStorage.setItem("theme", "dark");
    checked = !checked; // used for accessibility information in :aria-checked
  } else if (currentTheme == "dark") {
    rootElem.classList.add("light");
        rootElem.classList.remove("dark");
    localStorage.setItem("theme", "light");
    checked = !checked;
  }
};
</script>

<template>
  <div class="theme-switch-container">
    <button
      class="theme-switch"
      type="button"
      role="switch"
      aria-label="Toggle light or dark mode"
      :aria-checked="checked"
      id="theme-toggle"
      v-touch="toggleTheme"
    >
      <span class="theme-switch-slider">
        <span class="theme-switch-icons">
          <IconSun class="theme-switch-sun" />
          <IconMoon class="theme-switch-moon"/>
        </span>
      </span>
    </button>
  </div>
</template>

Unfortunately the @click native Vue event binding isn't mobile-friendly out of the box, and its @touchend mobile default option had bad UX for this specific case when I tested it. Because of that I ended up using the vue3-touch-events library with the default v-touch event it introduces, which behaves as tap event on mobile, and click on desktop. With this library it works consistently well on both mobile and desktop to toggle the button state.

Toggle CSS

The other essential part of the button working like a toggle is the CSS, which we do with three elements: the button itself, above theme-switch, the slider element theme-switch-slider (the background of the button, effectively), and the icon container theme-switch-icons (with two SVG icons inside).

/* ThemeToggle.vue */
<style scoped lang="postcss">
.theme-switch-container {
  display: flex;
}
.theme-switch {
  position: relative;
  border-radius: 1.5rem;
  display: block;
  width: 3.5rem;
  height: 1.75rem;
  background-color: var(--p-color-700);
  box-shadow: 0px 5px 15px 0px var(--p-color-400);
  cursor: pointer;
  transition: border-color 0.5s, background-color 0.5s;
}
.theme-switch-slider {
  position: absolute;
  top: 0px;
  left: 0px;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: 50%;
  background-color: var(--t-color-100);
  box-shadow: var(--t-color-900);
  transition: background-color 0.5s, transform 0.5s;
}
.theme-switch-icons {
  position: relative;
  display: block;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: 50%;
  overflow: hidden;
}
.theme-switch-icons svg {
  position: absolute;
  top: 0;
  left: 0;
}
.theme-switch-sun {
  opacity: 1;
}
.theme-switch-moon {
  opacity: 0;
}

.dark .theme-switch-slider {
  transform: translateX(1.75rem);
}
.dark .theme-switch-sun {
  opacity: 0;
}
.dark .theme-switch-moon {
  opacity: 1;
}
.dark .theme-switch-icons svg {
  transition: opacity 0.5s, color 0.5s;
}
</style>

With that we have a basic working dark/light toggle that flips the theme once we've loaded the page, but there are a few problems...

Saving theme preferences

The first is really a set of two problems: the dark mode setting is forgotten if you reload the page, and more importantly for accessibility the theme does not change based on existing browser level user preferences. To fix this we want the site to save the reader's preference and load it for any page on the site. For this approach to work across all pages on the site, the logic will go in our layout file, /layouts/default.vue.

In order for the site to check the reader's theme preferences on each page load we need to use a Vue lifecycle hook, in this case the onMounted. Now both the system/browser preferences information and any existing user preference can be checked on load like so:

onMounted(() => {
  // Check what the system color scheme preferences are
  try {
    // See references for more context for why "not all" is used here
    let media = window.matchMedia("not all and (prefers-color-scheme: light)"),
      rootElem = document.documentElement;
    // 
    const currentTheme = localStorage.getItem("theme")
      ? localStorage.getItem("theme")
      : null;
    if (currentTheme == "dark") {
      rootElem.classList.add("dark");
    } else if (currentTheme == "light") {
      rootElem.setAttribute("data-theme", "light");
    } else if (media.matches) {
      rootElem.classList.add("dark");
      localStorage.setItem("theme", "dark");
    }
    // catches browser/OS level preference changes while the page is already loaded
    media.addEventListener("change", () => {
      if (media.matches) {
        rootElem.classList.add("dark");
        localStorage.setItem("theme", "dark");
      } else {
        rootElem.classList.add("dark");
        localStorage.setItem("theme", "light");
      }
    });
  } catch (err) {}
});

Now when the page loads, if the browser prefers dark mode the theme will switch over after a brief load...but that means we've just blasted our dark mode reader with light for a few seconds too long!

'spongebob meme with some details'

There isn't any lifecycle hook in Vue that can catch this early enough to load straight into a dark theme, short of making it the default. To prevent the flash without making dark mode the default it's necessary to commit a small Javascript sin, adding a render blocking script to the head of the page.

By making the script load before anything else, we can check if we should set the dark theme and do it before the page is mounted, removing the flash. In Vue this is done with useHead().

// default.vue
useHead({
  script: [
    {
      children: `
      const theme = localStorage.getItem('theme');
      const darkModeMQ = window.matchMedia("not all and (prefers-color-scheme: light)");
      if (theme || darkModeMQ.matches) {
        document.documentElement.classList.add('dark')
      }`,
    },
  ],
});

Note that I'm repeating myself a little bit here, checking for the prefers-color-scheme media query in this render blocking script as well, but it's necessary. If we only check for the theme, as an example I found used, the render blocking only works for repeat visitors, it's necessary to also check the media query to prevent the flash for new visitors. The same logic in onMounted is still useful as it will set the key in localStorage, and acts as a fallback.

And now we have a full dark mode that detects the reader's preference and doesn't melt eyeballs!

Other considerations

Besides those specific technical implementation details, you're likely to run into a similar issue that I found, which was that text looked too bright in dark mode without any additional changes. After reading about it on CSS Tricks I fixed it by adjusting font weights in dark mode, favoring slightly lighter weights in most places. That change alone helped massively, without adjustments to the color values. But for this to work and look good you do need to deliberately choose fonts that support a wider range of weights. In my case I originally had Open Sans as my body font, with its lightest weight at 300. In testing 300 was not light enough for my design, so I ended up switching to Karla, where the 200 weight worked perfectly for what I needed.

For Nuxt, if you're using Google Fonts like me, you'll need to specify the weights you want, loading the font family with Karla: true on its own only includes the 400 weight. See the docs for examples of how to set them up in nuxt.config.ts.

Future plans

Overall I'm happy with the theme colors and the toggle implementation for now. My one remaining problem in testing was that images can be an issue. In the Epoch writeup, as an example, I include screenshots from light mode websites as part of the process, and those will remain blindingly white even on dark mode. Figuring out a solid approach to fix that, on an image-by-image basis is on my shortlist of improvements to make. Most likely this will be leveraging an invert filter, but I haven't dug into the research yet.

Footnotes