Implementing dark mode, for real this time

Years ago I wrote a blog post on creating dark mode with CSS variables. At the time it was more about jotting down the basic idea for my own benefit, but I never actually implemented it. A few weeks ago I felt the itch and decided I wanted to finally follow through for real.

I’m pleased to report that the basic idea works well — you’re looking at it right now — but in putting it into practice, I learned a bunch of new things: some quirks of the matchMedia object, setting and getting cookie values, and the finer points of HSL color spaces, among others. So this is to follow up on that original post and elaborate on some of the complexities I had originally hand-waved away.

This post is in four parts:

  1. Creating themes with CSS variables
  2. Storing user preferences with cookies
  3. Toggling between two themes with an input element
  4. Adding additional styling to the basic toggle

Creating themes with CSS variables

The strategy here is basically unchanged from my original post: create CSS variables for each colour, plus variations controlled by some selector. In this case, I'm using a custom HTML attribute on the body tag to toggle the theme on and off.

I opted to update my colour declarations to HSL so it was easy to keep things like hue and saturation consistent; I think it makes the colours feel more harmonious overall, both within and between the two themes. I think it’s also slightly easier to reason about compared to hex colours.

Here’s the relevant CSS:

:root {
  --hue: 215;
  --sat: 50%;
  --colorBgPrimary: hsl(var(--hue), var(--sat), 100%);
  --colorFgPrimary: hsl(var(--hue), var(--sat), 20%);
  --colorFgSecondary: hsl(var(--hue), 25%, 45%);
  --colorAccentPrimary: hsl(var(--hue), calc(var(--sat) * 2), 35%);
  --colorAccentSecondary: hsl(var(--hue), calc(var(--sat) * 2), 50%);
}

body[data-theme="dark"] {
  --hue: 200;
  --colorBgPrimary: hsl(var(--hue), var(--sat), 15%);
  --colorFgPrimary: hsl(var(--hue), var(--sat), 90%);
  --colorFgSecondary: hsl(var(--hue), 25%, 70%);
  --colorAccentPrimary: hsl(30, calc(var(--sat) * 2), 75%);
  --colorAccentSecondary: hsl(30, calc(var(--sat) * 2), 65%);
}

Saving the preference as a cookie

I knew I needed some way to save the user’s theme preference in the browser, since the alternatives were bad:

  • I’d have to pick one theme to be the default, and if the user wanted something different they’d have to toggle the colour-scheme with every page load.
  • All pages would load with the colour-scheme preference set at the OS level, and either have to toggle on each page load or have no option to switch at all.

So I knew I needed to set some sort of cookie to persist the user’s preference across page-loads and between sessions. Since I only have two theme options, light and dark, it felt like overkill to use a third-party cookie library, so I went looking for a minimum-viable cookie management strategy. In the end, this was my approach:

The first function reads a cookie value, and comes from this Gist by @wpsmith:

function getCookie (name) {
  let value = `; ${document.cookie}`;
  let parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop().split(';').shift();
}

The second function sets a key-value pair as a cookie. I just hard-coded the options, since my use-case is so limited. This cookie persists for a year (60 seconds, times 60 minutes in an hour, times 24 hours in a day, times 365 days). It also enforces HTTPS and SameSite which is perhaps a little paranoid, given that the cookie value doesn’t contain information of any value. But then again, why not default the most secure behaviour?

function setCookie (key, value) {
  document.cookie = `${key}=${value}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Strict; Secure;`;
}

Uglified, these two helper functions add up to just over 200 bytes, so I opted to just put them inline in the <head>.

Building the theme toggler

The actual theme-toggler affordance is a checkbox, which controls a data-theme custom attribute on the <body> tag. By default it’s set to light:

<body data-theme="light">
...
<label id="theme" for="theme_toggler">
  <input type="checkbox" id="theme_toggler" class="visuallyhidden">
</label>

The checkbox has some extra CSS jazz to make it look nice (see below) but it's still just a checkbox. The logic for manipulating that checkbox, however, had a few gotchas:

  • The window.matchMedia browser API seems undercooked to me. It requires a bunch of hacky string-parsing and generally feels brittle and error-prone.
  • matchMedia can also change at the OS level at any time, so that’s an event you need to listen for and (optionally) respond to.
  • Figuring out which default behaviours to fall back to got me a little twisted up. My first implementation was way too complex because I wasn't thinking from the default and building up the complexity from there.

I can’t do much about the quirks of matchMedia, but I can try to explain the theme-switching logic I came up with:

  • If there’s no theme cookie, get the browser setting, use that theme, and set a new cookie.
  • If there is a theme cookie, use that theme.
  • If the user manually toggles the theme, use that theme, and update the cookie.
  • If the browser/OS-level theme preference changes mid-session, get the new browser setting, use that theme, and update the cookie.
  • If all else fails, fall back to the default light mode. If the user has turned off JavaScript, for instance, they’ll see the light theme.

Below is the script. I’ve annotated it with comments to explain what’s going on:

// The checkbox
const toggler = document.querySelector('#theme_toggler');
// Caching the matchMedia color-scheme preference as a variable.
const media = window.matchMedia('(prefers-color-scheme: dark)');

// Function to return the system theme. `matchMedia` returns `matches: true` if
// the browser setting matches the media query passed above. It returns a
// string, either "dark" or "light".
const getSystemTheme = () => {
  return media.matches ? "dark" : "light";
}

// Function to perform all the steps involved in setting a new color scheme.
// Accepts a color string
const setTheme = (color) => {
  // set the cookie with the theme preference, as a key-value pair.,
  setCookie('theme', color)
  // update the custom attribute on the body tag with the new theme
  document.querySelector('body').dataset.theme = color;
  // update the state of the checkbox to match the new theme
  toggler.checked = color === 'dark' ? true : false;
  // handle the labelling of the checkbox for accessibility.
  let other = color === 'dark' ? 'light' : 'dark';
  toggler.ariaLabel = `Switch to ${other} mode`;
}

// If there’s a `theme` cookie, use it to set the color scheme
if ( getCookie('theme') ) {
  setTheme(getCookie('theme'))
// ...Otherwise, set the color scheme based on the browser preference
} else {
  setTheme(getSystemTheme())
}
// Listen for changes to the checkbox state 
toggler.addEventListener('change', () => {
  toggler.checked ? setTheme('dark') : setTheme('light');
});
// Listen for changes to the browser preference state
media.onchange = () => {
  setTheme(getSystemTheme())
}

At this point the toggler was working as intended:

  • On initial page load, the user gets their OS theme preference
  • The toggle reflects the current theme preference
  • The toggle can be checked on or off to change that preference
  • The preference is saved as a cookie and persists across page loads and sessions.

However, it just looked like a checkbox, and I figured I could make it look a little nicer.

Styling the checkbox toggle

I had two priorities with the more elaborate toggle style:

  • Keep it accessible by keyboard
  • Tweak the visuals using only CSS

Hiding the default checkbox UI while remaining accessible for screen readers and board navigation seems pretty straightforward, using the “visually hidden” method:

/* Accessibly hide elements */
.visuallyhidden {
  border: 0;
  clip: rect(0 0 0 0);
  height: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
  width: 1px;
}

The final step was deciding what the custom toggle should actually look like. I didn’t want to use a hefty SVG or have to figure out a ton of complex animation, so I’ve opted to style using only Unicode emoji — the "High Brightness Symbol" when in dark mode, and the "Waxing Crescent Moon Symbol" when in light mode.

/* Toggler control, absolute-positioned in the top right of the viewport */
#theme {
  display: grid;
  place-content: center;
  position: absolute;
  height: 2rem;
  width: 2rem;
  top: 1rem;
  right: 1rem;
  border: solid 2px transparent;
  border-radius: 3px;
}

/* Toggler focus outline style. The checkbox can still be focused,
   even though it’s visually hidden */
#theme:focus-within {
  border-color: var(--colorAccentPrimary);
}

/* In dark mode, show brightness symbol. In light mode, show moon symbol */
body[data-theme="dark"] #theme::before {
  content: "\1F506";
}
body[data-theme="light"] #theme::before {
  content: "\1F312";
}

Overall, this was a really good exercise in moving from theory to practice, and I’m glad that I (finally!) followed through. Plus I now see some additional threads to pull on, such as moving from just two binary options to a larger selection of themes.

I’ve boiled down the whole setup into a Gist with the most essential moving parts. Feel free to comment there with any questions or refinements.