Thumbnail for article 'How I implemented Dark Mode'

How I implemented Dark Mode

The page you are reading right now, the entire SkillBars website, training web app, and admin web app all support light and dark modes, which you can toggle with the header icon in the upper-right corner. I thought implementing a dark mode would be as simple as swapping out a few CSS variables and adding a theme toggle, but it turned out to be far more involved than I could have ever anticipated.

I learned a lot through this process and wanted to create a comprehensive guide to help other development teams who are thinking about adding a dark mode to their website and web apps:

Design Light and Dark Themes

I didn’t really know how to approach designing both a light and dark theme for the same UI, which ended up being a much bigger issue than I anticipated. Designing one color scheme was hard enough, but designing two that worked equally well was something I didn’t think I could do.

A breakthrough happened when I stopped thinking of the light mode as having a white background and dark mode as having a black background. My inspiration came from thinking about the colors of the two ninjas from G.I. Joe:

  • Storm Shadow is the white ninja but is actually light shades of gray.
  • Snake Eyes is the black ninja but is actually dark shades of gray.

By moving away from thinking in terms of white and black and instead envisioning the UI in terms of light grays and dark grays, things finally started to click:

G.I. Joe Ninjas

To better understand how light and dark grays interact, I did some experiments inspired by Josef Alber’s book, Interaction of Color, which explains that how I perceive color depends on the color that surrounds it. For example, in the image below, you’ll notice how you perceive a shade of gray changes based on what color background it is on:

Light and Dark Mode Color Theory

With a rough idea of which light and dark grays would form the basis of the light and dark modes, I focused on the three CSS properties that would be changing based on the color scheme:

  • color – for text and icons.
  • border-color – for borders.
  • background-color – for just about everything else.

To make things simple, I thought of the light or dark greys as the “background colors," and the primary colors (reds, yellows, blues) and secondary colors (greens, oranges, purples) as the “foreground colors."

In most instances, I kept the foreground colors the same across light and dark modes. However, when the foreground colors looked “washed out" in light mode, I would switch to using dark greys for foreground colors instead.

SkillBars Logo Exploration

I employed this technique for the logo, assessment questions, and pricing page. If you toggle the theme on these pages, you’ll notice the UI uses dark grays while in light mode, but full color while in dark mode.

Extract CSS Colors to Variables

As I were starting from an existing app that already had hard-coded colors in the CSS, I had to extract that CSS into variables to make theming possible. There are essentially two options for doing this:

  1. Use a CSS pre-processor (e.g. Sass, Less, or Stylus) to generate different CSS property values for each theme.
  2. Use CSS Custom Properties (often referred to as “CSS Variables") which are built into modern browsers.

I chose to go with the more modern solution of CSS Custom Properties (which I will refer to as “CSS Variables" from here on), but if you have to support older browsers - especially Internet Explorer - then you should use CSS pre-processors instead.

Once I made my decision to go with CSS Variable, I pulled all of the CSS colors into a central theme.css file, which generally looked like this:

SCSS
:root { // root refers to the HTML element
  --site-font-family: sans-serif;

  &[data-theme="light"] {
    color-scheme: light;
    --site-body-text: #444;
    --site-background-color: #fefefe;
  }

  &[data-theme="dark"] {
    color-scheme: dark;
    --site-body-text: #ddd;
    --site-background-color: #151515;
  }
}

The color-scheme CSS property tells the browser how you want the browser-supplied UI components to be themed. For example, using color-scheme: dark will make a page’s scrollbars and form inputs render in a dark color scheme. This is an incredible time savings over having to create variables for every form element and scrollbar yourself, and the results are much nicer.

As :root refers to the HTML element, and I had the variables setup to change based on the data attribute of the HTML element, all I needed to do to switch themes was to update the theme data attribute:

JS (Browser)
// documentElement refers to the HTML element
document.documentElement.dataset.theme = 'dark';

The HTML element then looked like:

HTML
<html data-theme="dark">
  ...
</html>

This has the effect of all variables under &[data-theme="dark"] to be used throughout your CSS, such as:

SCSS
body {
  color: var(--site-body-text); // #ddd in dark mode
  background-color: var(--site-background-color); // #151515 in dark mode
}

Choose How to Toggle Themes

I were not sure how I wanted the user to toggle themes, so I examined the user experience of other websites and web apps that supported dark mode. To keep track of what I found, I built a Google spreadsheet:

After doing my research, I narrowed down the options to:

  • Using a header icon with two states (light, dark).
  • Using a header menu with three options (light, dark, sync with device).

I decided on a header icon because:

  1. It would be easily discoverable by the user, as it was always visible at the top of the page.
  2. It would give immediate feedback as to what it did when it was clicked, as the entire UI would change instantly.

Additionally, user testing showed that users much preferred the single-click theme toggle over the perceived inconvenience of opening a header menu and selecting a theme option with the probability that two of the three options would not visually change the theme if selected.

For the theme icon I went with the standard sun and quarter-moon, as well as a “dark-blackout" icon which I’ll discuss later on:

SkillBars Theme Icons

I also decided to sync with the user’s device theme, with this default being overridden if the user toggles the header icon to switch between the light and dark modes. Unfortunately, a toggle icon would not give the user an option to re-sync with their device’s theme, but I did not believe this would be a major concern unless the following are all true:

  1. The user has their device set to automatically switch to light mode during the day and dark mode at night.
  2. The user uses the website or web apps both during the day and at night.
  3. The user does not find it convenient to use the header icon to manually change the theme after their device theme automatically switches.

Further, over half of the websites I looked at did not have the option to re-sync to the user’s device settings at all. However, I still wanted to support the feature, so I added a “Sync to Device" button in the FAQ. Admittedly, most users may never think to look in the FAQ for a solution to re-sync with their device’s theme, but if a user were to ask us how to do it, I could at least send them a link to the relevant section of the FAQ.

Store the User's Theme Choice

Once I decided to move forward with the header icon theme toggle, the next step was to determine how best to store the user’s theme choice. In my research of how other sites handled this, I saw that the majority store the user’s theme choice on the user’s device and not their account. In my case, this was my only option, as I wanted non-authenticated users to be able to toggle themes as well.

To store a theme choice for non-authenticated users, there are two choices:

  1. LocalStorage - which has an easy-to-work-with browser API.
  2. Cookies - which have a not-so-easy-to-work-with browser API.

Initially, I wanted to use LocalStorage as I do throughout the web apps to store local state, but LocalStorage has two issues when it comes to storing themes:

  1. LocalStorage does not transfer across subdomains, so I could not have the same theme setting transfer across the “www" and “app" subdomains without doing something convoluted like loading hidden iframes from the other subdomain and transferring the theme choice with postMessage.
  2. LocalStorage is not readable by the server, and being read by the server would allow us to write the theme directly into the HTML be served, which was needed to support several features.

Considering these technical constraints, I decided to store the user’s theme preference in a cookie. Cookies, however, have a bad reputation, especially considering the European Union’s GDPR regulation. According to the GDPR, a theme cookie would be described as:

  • Duration: Persistent – The theme cookie sticks around until it expires.
  • Provenance: First-party – The theme cookie is only used by the domain that set it.
  • Purpose: Preferences – The theme cookie stores a user preference.

My interpretation of the GDPR is that theme cookies are not the “bad" type of cookie, but you should check your organization’s cookie usage and disclosure policy before deciding to use a user’s theme choice in a cookie.

As cookies don’t have an intuitive API, I wanted an API wrapper to make them easier to work with. I considered using js-cookie as the wrapper but wanted to encapsulate my specific needs rather than needing to pass in specific parameters with every call. I could have written an adapter that wrapped js-cookie – which was itself wrapping the native cookie class. Instead, I ended up writing my own Cookies class:

JS (Browser)
class Cookies {
  static PRODUCTION_DOMAIN = 'skillbars.com';  // Change to yours
  static EXPIRY_MS = 400 * 24 * 60 * 60 * 1000;  // Chrome Max Allowable
  static PATH = '/';
  static SAME_SITE = 'Strict';

  constructor() {
    this.domain = this._getDomain();
  }

  _getDomain() {
    const hostname = document.location.hostname;
    if (hostname.includes(Cookies.PRODUCTION_DOMAIN)) {
      return Cookies.PRODUCTION_DOMAIN;
    } else {
      return hostname;
    }
  }

  get(name) {
    const cookieNameEquals = `${name}=`;
    const allCookies = document.cookie.split(';');
    for (let cookie of allCookies) {
      cookie = cookie.trim();
      if (cookie.startsWith(cookieNameEquals)) {
        return cookie.substring(cookieNameEquals.length);
      }
    }
    return null;
  }

  set(name, value) {
    const date = new Date();
    date.setTime(date.getTime() + Cookies.EXPIRY_MS);
    const expires = date.toUTCString();
    this._saveCookie({ name, value, expires });
  }

  delete(name) {
    const expires = 'Thu, 01 Jan 1970 00:00:01 GMT';
    const value = '';
    this._saveCookie({ name, value, expires });
  }

  _saveCookie({ name, value, expires }) {
    const cookieParts = [
      `${name}=${value}`,
      `expires=${expires}`,
      `path=${Cookies.PATH}`,
      `SameSite=${Cookies.SAME_SITE}`,
      `domain=${this.domain}`
    ];
    if (document.location.protocol === 'https:') {
      cookieParts.push('secure');
    }
    document.cookie = cookieParts.join('; ');
  }
}

Though generally a bad practice (as it pollutes the global namespace), I chose to effectively make cookies a singleton by adding an instance to window:

JS (Browser)
window.cookies = new Cookies();

Note that this is only effectively a singleton – and not technically a singleton - as you can still create multiple instances, but only one instance would ever exist on window.cookies. Doing this streamlined the cookie API so that it was more similar to localStorage, allowing me to write the theme-switching function like this:

JS (Browser)
const switchThemeTo = theme => {
  document.documentElement.dataset.theme = theme;
  cookies.set('theme', theme); // No need to specify window as it’s global
};

With the theme-switching function written, all that was needed was to attach a click event handler to a theme toggle element, which in my case was a header button containing the theme icon:

JS (Browser)
const themeToggle = document.getElementById('#theme-toggle');

themeToggle.addEventListener('click', event => {
  const currentTheme = document.documentElement.dataset.theme;
  switchThemeTo(currentTheme === 'light' ? 'dark' : 'light');
});

Eliminate "White Flash" on Load

One of my earliest dark mode requirements was to not have a “white flash" as pages load into dark mode. This white flash is a Flash of Unstyled Content (FOUC), as the browser starts rendering its default white background before it loads the CSS that tells it to render a dark background.

If there is theme cookie set by the user, eliminating the white flash only requires that you pass the theme variable into the template being rendered into HTML. In this example I are using Node Express with Pug as the template renderer, but the same concept applies in any server environment:

JS (Server)
app.get('/', (req, res) => {
  const themeWhitelist = ['light', 'dark'];
  const defaultTheme = 'light';

  const theme = themeWhitelist
    .includes(req.cookies.theme) ? req.cookies.theme : defaultTheme;

  res.render('index', {theme});
});

For security reasons, it is extremely important to whitelist the values of the theme cookie before writing the cookies value into the HTML. If you do not, and instead allow anyone to pass any value in the theme cookie, and then write that value into your HTML, you create an attack vector for XSS. Though the HTTP Content Security Policy prevents the running of scripts without a nonce, it’s generally a good idea to have defense in depth.

With the theme value obtained from the cookie, it can them be used in the Pug template to render the HTML before it is downloaded to the browser – long before a “White Flash" even has a chance to happen:

Pug
html(data-theme=theme) 
  head
    ...
  body
    ...

Where things get more complicated is when you do not have a theme cookie, and:

  1. You load a light theme by default.
  2. You sync your theme to the user’s system theme.
  3. The user has a dark system theme.

As I can only know the user’s system theme choice through JavaScript, the theme must be set on the HTML element before the body tag to avoid a white flash. This has to be done sooner than DomContentLoaded fires, so needs to done in the head tag so that it happens before the body tag renders.

The pug template to load the necessary files would look something like this:

Pug
html(data-theme=theme)
  head
    ...
    script
      include cookies.js // The Cookies class from earlier
      include themes.js
  body
    ...

themes.js would include the previous switchThemeTo and themeToggle code as well as:

JS (Browser)

const deviceSetting = window.matchMedia('(prefers-color-scheme: dark)');
const deviceSetToDarkMode = deviceSetting.matches; 
const cookieTheme = cookies.get('theme');

if (['light', 'dark'].includes(cookieTheme)) {
  switchThemeTo(cookieTheme);
} else if (deviceSetToDarkMode) {
  switchThemeTo('dark');
} else {
  switchThemeTo('light');
}

While I’ve got a reference to deviceSetting, now is also a good time to handle when the user switches their device theme so that I can update the page’s theme at the same time (a very cool effect on MacOS):

JS (Browser)
deviceSetting.addEventListener('change', event => {
  const cookieTheme = cookies.get('theme');
  if (cookieTheme != null) {
    return;
  }
  const deviceSetToDarkMode = event.matches;
  if (deviceSetToDarkMode) {
    switchThemeTo('dark');
  } else {
    switchThemeTo('light');
  }
});

It’s worth noting that the color-scheme meta tag is intended to help to eliminate the “White Flash" problem, but as I understand how it works, it’s less deterministic than simply setting the theme you want on the HTML element, and you achieve the same effect by using the color-scheme CSS property.

Verify that Colors Are Accessible

Once the theming was implemented to the point where I could switch between light and dark modes, I was able to use browser-based accessibility testing tools to verify that my color choices had an accessible amount of contrast:

  • Google Lighthouse Accessibility Audits – Although not as thorough at accessibility testing, Lighthouse is integrated directly into Chrome dev tools and allows you to run other tests at the same time.
  • WAVE – Identifies more issues than Google Lighthouse and offers in-page visual indicators as to where there are accessibility issues are located.
  • Axe – The tool most people think of when they think of browser accessibility testing, as many consider it the gold standard.

These tests identified many accessibility issues with the website and web apps, all of which were eventually addressed. Specifically, with regard to the light and dark mode theming, I had issues related to color contrast. For example, the initial link colors did not have enough contrast with their background. I ended up using different link colors to have sufficient contrast in both light and dark modes:

I also ran into an issue with the “Sample" icon. I wanted the icon to be white text on a red background so that it stood out to users like a notification badge, but it got flagged as not having enough contrast. In trying other options, I found I didn’t like the look of black text on a red background, and though a yellow button with black text looked great on a dark background, the yellow was washed out on a light background:

Accessible Sample Button Example

I settled on using the same blue as I do for all the oversized buttons throughout the website, which passed the accessibility color contrast tests, and also looked good on both light and dark backgrounds.

Theme the Images

There are three ways to theme images for light and dark modes:

CSS filter

I found that while in dark mode – especially when viewing the UI at night – the whites in the photos were a little too bright, so I toned down the image brightness slightly while in dark mode using a CSS filter property variable:

SCSS
:root {
  &[data-theme="light"] {
    ...
    --img-filter: none;
  }

  &[data-theme="dark"] {
    ...
    --img-filter: brightness(.9);
  }
}

I then use the --img-filter CSS variable wherever I wanted to reduce the brightness of images:

SCSS
article {
  img {
    filter: var(--img-filter);
  }
}

You can also use CSS filters to theme more than just images. For example, I used filters to theme the embedded Google spreadsheet from earlier while in dark mode:

SCSS
:root {
  &[data-theme="light"] {
    ...
    --iframe-filter: none;
  }

  &[data-theme="dark"] {
    ...
    --iframe-filter: invert(1) hue-rotate(175deg) contrast(.8);
  }
}

Some notes on how the iframe filter works:

  • invert – Flips the colors so the black text becomes white, and the white background becomes black. This also has the effect of making the blue links yellowish.
  • hue-rotate – Changes the now yellowish links back to blue.
  • contrast – Tones down the whites into a light gray and lightens the black to become a dark gray.

I also use filters to enable a dark-blackout theme, a hidden feature, which allowed us to verify that a user doesn’t need to perceive color to use the website and web apps. You can enable the dark-blackout theme by:

  1. Switching to dark mode.
  2. Holding down the dark mode icon (the moon) for 3 seconds.

Here’s how I added the dark-blackout theme:

SCSS
:root { 
  ...
  &[data-theme="dark-blackout"] {
    body {
      filter: grayscale(1) invert(1);
      background-color: black;
      color-scheme: dark;
    
      img {
        filter: invert(1);
      }
    }
  }
}

The reason I invert the images after the invert on the body is that the body invert often gives photos of people white hair with black teeth. The img invert has the effect of reversing the body invert, making photos look like normal greyscale images.

If the technique I used to create the dark-blackout theme is all you need to create your dark mode, you should give it a try. At a minimum, it might be a quick way to add a dark mode without all the hassle of designing themes and extracting CSS variables.

CSS background-image

If you have both a light and dark-mode version of an image, you can use a CSS background image to swap out which image is shown based on the theme. To do this, your themes.js file would look like:

SCSS
:root {
  &[data-theme="light"] {
    --some-themed-image-url: url(/.../themed-image-light.png);
  }
  &[data-theme="dark"] {
    --some-themed-image-url: url(/.../themed-image-dark.png);
  }
}

The CSS for setting up the themed images would look like:

SCSS
.themed-image {
  background-repeat: no-repeat;
  background-size: cover;
  background-position: center center;
}

.some-themed-image {
  width: 10rem;
  height: 10rem;
  background-image: var(--some-themed-image-url);
}

Instead of using an img tag, you would use this:

HTML
<div class="themed-image some-themed-image"/>

This method works best if you know the size of the image ahead of time as it’s good practice to set the size of your image before they load to avoid layout shifts.

Updating img src

As I wanted images to be themed, but I also wanted them to be indexed by Google (Google does not index CSS background images), I opted for switching out the image URL, which I do on the server as well as the client. I use this technique on the homepage to switch all the screenshot images based on the user’s theme choice.

To change the image source on the server, I use a regular expression to replace occurrences of the patterns I use in the themed images, which looks like:

HTML
<img src="some-image--theme-light@670w.png"/>
// or
<img src="some-image--theme-dark.png"/>

Then when serving a page, I use a regular expression to match and replace this pattern:

JS (Server)
app.get('/', (req, res) => {
  ...
  pageHtml = pageHtml
    .replace(/--theme-(light|dark))/g, `--theme-${theme}`);
  ...
});

There are performance implications for doing this replacement on every request, but it was worth it to us to avoid the browser starting to download the wrong image before I set the correct theme on the client – which I found was happening even though I lazy-load images.

When the user switches the theme on the client, I replace all the images with their themed counterparts using the same regular expression:

JS (Browser)
const updateImagesToTheme = theme => {
  const updateAttribute = (selector, attribute) => {
    document.querySelectorAll(selector).forEach(el => {
      el[attribute] = el[attribute]
        .replace(/--theme-(light|dark))/g, `--theme-${theme}`);
    });
  }

  updateAttribute('.themed-image img', 'src');
  updateAttribute('.themed-image source', 'srcset');
}

I update both img src and source srcset because I use WebP for the screenshots with a PNG fallback:

HTML
<picture>
  <source type="image/webp" srcset="/.../screenshot--theme-dark.webp"/>
  <img src="/.../screenshot--theme-dark.png" width="670" height="665"/>
</picture>

WebP does a better job at lossless compression but was not supported by Safari until relatively recently. Lossless compression works best for screenshots as lossy compression (as is used for JPEGs) tends to make the details of a screenshot fuzzy.

I could then use updateImagesToTheme in the switchToTheme function:

JS (Browser)
const switchThemeTo = theme => {
  document.documentElement.dataset.theme = theme;
  cookies.set('theme', theme);
  updateImagesToTheme(theme);
};

Note that because I use lazy loading, and that the theme switch icon is in the header, and the screenshots on the homepage are below the fold, I don’t incur an immediate download penalty when I do the theme switch.

Theme the Font Icons

I created my own font icons by:

  • Designing icons in Adobe Illustrator, each on their own individual artboard.
  • Using “Export for screens" in Illustrator to export each artboard as its own SVG.
  • Importing the individual SVGs into IcoMoon to generate the font icons.

With the icons converted to font icons, I themed them using:

SCSS
.font-icon {
  color: var(--icon-color);
}

However, I had a specific requirement for skill badge icons, in that they needed a black outline so that the white icons were easy to see on any color background.

The first thing I tried was multiple text-shadows, where you essentially stack shadows until you get something that looks like a border.

SCSS
.skill-badge {
  background-color: var(--module-badge-background);
  border: solid 1px var(--module-border);
  border-radius: 50%;
  height: 2rem;
  width: 2rem;
  display: flex;
  justify-content: center;
  align-items: center;

  .font-icon {
    font-size: 3rem;
    color: white;
    text-shadow:
      0 -.1rem black,      // Top
      0 .1rem black,       // Bottom
      -.1rem 0 black,      // Left
      .1rem 0 black,       // Right
      -.1rem -.1rem black, // Top Left
      .1rem -.1rem black,  // Top Right
      -.1rem .1rem black,  // Bottom Left
      .1rem .1rem black;   // Bottom Right
  }
}

Unfortunately, this technique didn’t result in a smooth outline. I then tried using -webkit-text-stroke, but found it did not work the way I need, as the strokes were centered on the icon’s edge instead of outside of the icon, and there was no way to modify this behavior. This image helps to explain the issues:

How -webkit-text-stroke Works

My solution to get the effect I wanted was to stack two of the same icon on top of one another, with the white icon on top and the icon on the bottom having a -webkit-text-stoke border that was twice as thick as I needed. This image helps explain how this works:

How I got font-icon outlines

The code to get this effect looks like this:

HTML
<span class="skill-badge">
  <i class="font-icon listening-icon"></i>
  <i class="font-icon listening-icon"></i>
</span>
SCSS
.skill-badge {
  background-color: var(--skill-badge-background);
  border: solid 1px var(--skill-badge-border);
  border-radius: 50%;
  height: 2rem;
  width: 2rem;
  position: relative;

  .font-icon {
    font-size: 3rem;
    position: absolute;
    inset: 0;
    display: flex;
    justify-content: center;
    align-items: center;

    &:first-child {
      color: black;
      z-index: 1;
      -webkit-text-stroke: .2rem black;
    }

    &:last-child {
      color: white;
      z-index: 2;
    }
  }
}

Theme the Embedded SVGs

As SVGs are XML documents, they can be embedded in HTML documents so that their elements are selectable and styleable by CSS:

HTML
<html>
  <body>
    <svg/>
      ...
    </svg>
  </body>
</html>

To style an SVG, you first need to prepare it for embedding into HTML. For example, if you were to export an SVG from Adobe Illustrator and open it in a text editor, it would have a format similar to this:

SVG
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
  <defs>
    <style>
      .cls-1 {
        fill: blue;
      }
      .cls-2 {
        fill: red;
      }
    </style>
  </defs>
  <rect class="cls-2" width="100" height="100"/>
  <circle class="cls-1" cx="50" cy="50" r="25"/>
</svg>

Note that this this SVG’s code results in:

  • A red square as the background.
  • A centered blue circle in the foreground.

To prepare this SVG for embedding in HTML you would:

  1. Remove the xml element.
  2. Remove the data-name attribute.
  3. Give the .cls-* classes meaningful names.
  4. Give it the SVG a meaningful id attribute.
  5. Delete the defs element and its contents.

After following the steps, when the SVG is embedded in the HTML it should look like this:

HTML
<html>
  <body>
    <svg id="circle-in-square" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
      <rect class="background" width="100" height="100"/>
      <circle class="foreground" cx="50" cy="50" r="25"/>
    </svg>
  </body>
</html>

Now I can style it with CSS variables as I do for any other CSS element, but instead of using CSS background-color I use the SVG-specific property fill:

SCSS
#circle-in-square {
  .background {
    fill: var(--site-background-color); 
  }
  .foreground {
    fill: var(--site-body-text);
  }
}

Theme the App Icon

There are two main use cases for what might be called the “app icon:"

  1. The icon shown in the browser tab. Traditionally, this was handled by the favicon.ico, but browsers today handle this with link and meta tags in the head of a HTML document.
  2. The icon shown when a URL is used as a shortcut, such as when you use the “Add to Home Screen" feature of Safari on iOS.

I can’t theme favicon.ico, as it’s a file with that specific name at the root of your web domain. However, I can theme the App Icons referenced in the link and meta tags. This gives us full control over the browser tab and shortcut icons, as favicon.ico today is just a fallback for older browsers.

The first step in theming the app icon was to generate a set of icons for both light and dark modes. I prepared a 310px x 310px version of the logo in light and dark modes, and used Favic-o-Matic to generate:

  • The traditional favicon.ico fallback (I only used the light mode version as a default for older browsers)
  • All the app icons at their various sizes.
  • The link and meta tags that can be inserted into the Pug templates after I converted the HTML to Pug.

This resulted in two zip files (one for each of the themed logos) which I placed into separate theme directories on the image server. I then passed in the theme variable to select the appropriate directory based on the theme:

Pug
html(data-theme=theme)
  head
    ...
    link(
      rel='apple-touch-icon-precomposed'
      sizes='57x57'
      href=`/.../app-icons-${theme}/apple-touch-icon-57x57.png`
    )
    // All of the other app icon formats
    meta(
      name='msapplication-square310x310logo'
      content=`/.../ app-icons-${theme}/mstile-310x310.png`
    )
    ...
  body
    ...

I could have changed the name of all of the individual icon files to end with “--theme-light.png" and “--theme-dark.png" so that server-side theme replacement (that was described earlier in Updating img src) would work like it did for any other image, but using a directory for each theme avoided the need for renaming each file, and made adding new revisions generated from Favic-o-Matic much easier.

While this technique works when loading a page when a theme cookie has been set, it doesn’t work when there is no cookie. It also doesn’t work when the user toggles the theme switch button. To support those use cases, I needed to add this code on the client:

JS (Browser)
const updateAppIconsToTheme = (theme) => {
  const oldTheme = theme === 'light' ? 'dark' : 'light' ;

  const replaceAllIcons = (tag, attr) => {
    document.querySelectorAll(`${tag}[${attr}*="favicon"]`)
      .forEach(el => el[attr] = el[attr].replace(oldTheme, theme));
  }

  replaceAllIcons('link', 'href');
  replaceAllIcons('meta', 'content');
};

I can then use updateAppIconsToTheme in the switchToTheme function:

JS (Browser)
const switchThemeTo = theme => {
  document.documentElement.dataset.theme = theme;
  cookies.set('theme', theme);
  updateImagesToTheme(theme);
  updateAppIconsToTheme(theme);
};

Theme the App Screenshots

My design philosophy for the homepage was for it to function like an infographic so that people could understand what I do all in one place. The design required several screenshots of the app, which in the beginning, I did by taking screenshots and preparing the images in Photoshop.

As time went on and the UI designs were refined, having to update all the various screenshots for each theme became very time-consuming. I decided to invest in automation so that I didn’t have to generate screenshots by hand every time the UI changed.

The code below is a simplified version of how I generated all the screenshots on the homepage:

JS (Local)
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  await page.setViewport({
    width: 800,
    height: 600,
    deviceScaleFactor: 2 // Gives us higher quality but oversized image
  });

  const takeScreenshot = async (url, screenName) => {
    await page.goto(url, { waitUntil: 'networkidle2' });

    for (const theme of ['light', 'dark']) {
      await page.evaluate(() => switchThemeTo(theme));
      await page.screenshot({
        path: `./raw-screenshots/${screenName}--theme-${theme}.png`
      });
    }
  };

  await takeScreenshot('https://.../app-screen-1', 'app-screen-1');
  await takeScreenshot('https://.../app-screen-2', 'app-screen-2');

  await browser.close();
})();

When previously preparing the screenshots by hand in Photoshop, I got spoiled by Photoshop's smooth downscaling. To get the same effect with screenshots taken by Puppeteer, I used a higher device scale factor and then resized the screenshots down with Sharp. Since I were doing image post-processing, I took the opportunity to also add a cache-busting token and a WebP format:

JS (Local)
const sharp = require('sharp');
const fs = require('fs-extra');
const path = require('path');
const {nanoid} = require('nanoid');
const toPath = file => path.join(__dirname, file);

(async () => {
  const screenshotWidth = 670; // Final image size with smooth downscaling
  const screenshots = [];
  const cacheBustToken = nanoid();
  const outputDir = "./processed-screenshots";
  fs.emptydirSync(toPath(outputDir));

  const files = fs.readdirSync('./raw-screenshots');

  for (let file of files) {
    let fileNoExt = file.replace('.png', '');
    let screenshotGroup = fileNoExt.replace(/--theme-.+$/, '');
    let input = toPath(`./raw-screenshots/${file}`);
    let outputFileNoExt = `${fileNoExt}@${screenshotWidth}w-${cacheBustToken}`;

    let webpOutput = toPath(`${outputDir}/${outputFileNoExt}.webp`);
    await sharp(input)
      .resize(screenshotWidth)
      .webp({lossless: true})
      .toFile(webpOutput);

    let pngOutput = toPath(`${outputDir}/${outputFileNoExt}.png`);
    let info = await sharp(input)
      .resize(screenshotWidth)
      .png()
      .toFile(pngOutput);

    screenshots.push({
      screenshotGroup,
      outputFileNoExt,
      width: info.width,
      height: info.height
    });
  }

  const screenShotsJson = JSON.stringify(screenshots, null, 2);

  fs.writeFile('./screenshots.json', screenShotsJson);
})();

I used the generated screenshots.json file to build the HTML for each screenshot on the homepage, as it allowed us to know the height of each image automatically post-resize.

JS (Local)
[
  {
    "screenshotGroup": "app-screen-1 ",
    "outputFileNoExt ": "app-screen-1--theme-light@670w-randcachebusttoken",
    "width": 670,
    "height": 503
  },
  {
    "screenshotGroup": "app-screen-1 ",
    "outputFileNoExt ": "app-screen-1--theme-dark@670w-randcachebusttoken",
    "width": 670,
    "height": 503
  },
  ...
]

Before, during, and immediately after automating screenshots, I had real doubts as to whether it would be worth the effort. Looking back, the days I spent on automation saved us weeks of project time. However, this effort is only worth it if you use a lot of themed app screenshots; otherwise, manual preparation in Photoshop might make more sense.

Theme the Social Share Image

The social sharing code that enables the sharing buttons at the bottom of every page of the website and training demo was modified to add a query parameter when in dark mode:

JS (Browser)
const socialSharer = network => {
  let url = window.location.href;

  if (document.documentElement.dataset.theme === 'dark') {
    const appendCharacter = url.includes('?') ? '&' : '?';
    url += `${appendCharacter}theme=dark`;
  }

  url = encodeURIComponent(url);

  const title = encodeURIComponent(document.title);  

  const shareUrls = {
    facebook: `https://www.facebook.com/sharer/sharer.php?u=${url}`,
    twitter: `https://twitter.com/intent/tweet?url=${url}&text=${title}`,
    linkedin: `https://www.linkedin.com/shareArticle?url=${url}&title=${title}`,
    reddit: `https://www.reddit.com/submit?url=${url}&title=${title}`
  };

  const shareUrl = shareUrls[network];

  window.open(shareUrl, '_blank', 'width=600,height=600');
}

With a ?theme=dark parameter added to any incoming request for a link preview, I used that parameter to set the theme:

JS (Server)
app.get('/', (req, res) => {
  const themeWhitelist = ['light', 'dark'];
  const defaultTheme = 'light';

  const themeFromQuery = req.query.theme;
  const themeFromCookie = req.cookies.theme;

  const theme = themeWhitelist.includes(themeFromQuery)
    ? themeFromQuery
    : themeWhitelist.includes(themeFromCookie)
      ? themeFromCookie
      : defaultTheme;

  const socialShareImage = `/.../social-share-image--theme-${theme}.png`
  res.render('index', {theme, socialShareImage})
});

This allowed us to write in the correct theme for the sharing preview meta tags, so that the themed HTML would be returned to whatever service requested the link preview:

Pug
html(data-theme=theme)
  head
    ...
    meta(property='og:image' content=socialShareImage)
    ...
    meta(name='twitter:image' content=socialShareImage)
    ...
  body
    ...

You can go to the homepage, click the share icons at the bottom, and when you share the link, you should see a different preview image based on your selected theme:

If you found this article helpful:

  • Share this article so that more people learn about it.
  • Share the SkillBars training solution with people looking to deploy human skills training across their organization.

If you have feedback, suggestions, or fixes for this article or want to learn more about the SkillBars soft skills training solution, you can reach out to me at rob@skillbars.com.