Mixing Metaphors

Coding my way out of a paper bag

Dark Mode Color Theme

This page describes my design and implementation of dark mode for this site, along with an interactive color editor.

Dark mode is an accessibility feature, along with an aesthetic choice. Both themes, dark and light, can cause eye strain under the wrong lighting conditions, so friendly sites provide an option.

You can toggle dark mode with this button:

Dark mode is gaining traction. iPhone will support dark mode in iOS 13, expanding on Apple’s 2018 introduction of system-wide Dark Mode for macOS. Many designers will follow Apple’s push. Dark mode is especially popular for conserving power on OLED displays.

I set out to create light and dark color palettes for this site.

Goals

  1. Theme agnostic design: style components without duplicating work for both themes.
  2. Find contrasting shades, so text is always legible.
  3. Generate both themes from a base palette, so colors are consistent, and easily adjustable.
  4. Follow user preference between light and dark themes. Match system theme where available.

Theme Agnostic Design

When a component looks right in one theme, it should automatically work in the other theme.

I decided on a mirrored color system to support both themes. Each color has shades of background and foreground, instead of light and dark:

background 3
background 2
background 1
foreground 1
foreground 2
foreground 3

The opposite theme has the same shades, in reversed order:

background 3
background 2
background 1
foreground 1
foreground 2
foreground 3

With a mirrored color system, switching between light and dark themes simply flips the foreground and background shades. I learned this technique from Pete Woodhouse’s Medium article on UI colour systems.

Here are some example styles using these shades:

Button state themes:

button
hover
active
disabled
button
hover
active
disabled

Sample buttons (try toggling dark mode, too):

Color Shading

I want to input arbitrary colors, and adjust until everything looks good. My theme generator takes these base colors as a hint, and finds shades with sufficient contrast.

It generates shades with three strength levels:

  1. Level 1 are the strongest shades, as close to base color as possible. Both modes use them (flippped from each other) as background 1 and foreground 1. The main text color (white or black) should be very clear on any background, so all level 1 shades should contrast well against their opposite pure color.
  2. Level 2 shades should stand out from all opposite level 1 shades. I never plan to put any foreground 1 against background 1. I want a weaker shade, foreground 2, which can be used against background 1 of any color. This also means foreground 1 can be used against background 2.
  3. Level 3 is even weaker than level 2.

The strictest accessibility standards require a contrast ratio of at least 7:1. Level 1 uses a contrast ratio of 9:1, and level 2 uses 7.1:1.

Computed Shade Palette

gray-3
gray-2
gray-1
gray-1
gray-2
gray-3
red-3
red-2
red-1
red-1
red-2
red-3
orange-3
orange-2
orange-1
orange-1
orange-2
orange-3
yellow-3
yellow-2
yellow-1
yellow-1
yellow-2
yellow-3
green-3
green-2
green-1
green-1
green-2
green-3
cyan-3
cyan-2
cyan-1
cyan-1
cyan-2
cyan-3
blue-3
blue-2
blue-1
blue-1
blue-2
blue-3
purple-3
purple-2
purple-1
purple-1
purple-2
purple-3
magenta-3
magenta-2
magenta-1
magenta-1
magenta-2
magenta-3

Theme Generator

This tool generates background and foreground shades from a base palette, creating a mirrored color system.

Pick new base colors, or change the contrast requirements.

Color App Loading...

Following User Preference

Web pages can honor system dark mode via CSS media query: prefers-color-scheme. Firefox and Safari currently support dark mode, with Chrome soon joining.

1
2
3
@media (perfers-color-scheme: dark) {
    /* rules here apply only in dark mode */
}

To apply the current theme across all components, I use CSS custom properties:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
:root {
  --theme-name: light;
  --theme-fg: black;
  --theme-bg: white;
}

@media (perfers-color-scheme: dark) {
  /* override light theme */
  :root {
    --theme-name: dark;
    --theme-fg: white;
    --theme-bg: black;
  }
}

/* apply colors from the current theme, using:
       var(--theme-*)
*/
body {
  background: var(--theme-bg);
  color: var(--theme-fg);
}

The dark mode toggle button uses JavaScript to copy a theme’s CSS rules into the body, overriding the browser’s default.

First, search all loaded CSS rules to find each theme:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// scan all loaded style sheets for theme rules.
//
// return {dark, light}: Arrays of CSS rule strings
function loadThemeRules() {
  const rules = Array.from(document.styleSheets).flatMap((sheet) =>
    Array.from(sheet.cssRules).flatMap((rule) =>
      // expand media query rule children
      rule.cssRules ? Array.from(rule.cssRules) : rule
    )
  );

  return rules.reduce((themes, rule) => {
    const themeName =
      rule.style &&
      rule.style.getPropertyValue('--theme-name').trim();
    if (themeName) {
      themes[themeName] = themes[themeName] || [];
      themes[themeName].push(rule.cssText);
    }
    return themes;
  }, {});
}

Next, reverse themes when the button is clicked:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function toggleTheme({dark, light}) {
  const name = window.getComputedStyle(
      document.documentElement
    ).getPropertyValue('--theme-name')
    .trim();
  if (name === 'light') {
    setTheme(dark);
  }
  else {
    setTheme(light);
  }
}

function setTheme(rules) {
  const id = 'theme-style-override';
  let elem = document.getElementById(id);
  if (!elem) {
    elem = document.createElement('style');
    document.body.appendChild(elem);
  }
  elem.innerHTML = rules.join('');
}

The user’s preference should persist across pages. This site uses localStorage to save dark mode preference; your preference is never sent to a server. Unfortunately, pages may initially load with the incorrect theme for a split second before JavaScript runs. I might be able to solve this with a ServiceWorker (and IndexdDB since localStorage is unavailable in Workers).