Toggle theme

Supporting dark mode in web pages

Light-on-dark color schemes (also called dark mode, night mode, or dark themes) have become popular in user interfaces. This page describes various techniques that I've used to add support for dark mode to web pages and web apps.

Matching the system theme

In mid-2019, almost all major browsers1 added support for the prefers-color-scheme CSS media feature. prefers-color-scheme takes the value dark or light depending on the user's operating-system-level dark mode setting.

web.dev recommends creating separate dark.css and light.css stylesheets that set CSS variables on the :root pseudoclass:

/* dark.css */
:root {
  --color: white;
  --background-color: black;
}
/* light.css */
:root {
  --color: black;
  --background-color: white;
}

The main stylesheet then uses those variables when styling the page, and also sets the color-scheme CSS property to tell the browser to style components and customize the user-agent stylesheet appropriately based on the system setting:

/* main.css */
body {
  color: var(--color);
  background-color: var(--background-color);

  color-scheme: light dark;
}

In the HTML file, media queries tell the browser to use the appropriate stylesheet:

<link rel="stylesheet" href="/dark.css" media="(prefers-color-scheme: dark)" />
<link rel="stylesheet" href="/light.css" media="(prefers-color-scheme: light)" />
<link rel="stylesheet" href="/main.css" />

Letting the user toggle the theme

The above technique works and doesn't require any JavaScript, but I wanted to give users the ability to toggle between the light and dark themes:

  • Due to accessibility concerns or lighting conditions, the user might have trouble reading the page when using the automatically-chosen theme.
  • The user's operating system might not let them enable dark mode, resulting in the browser never setting prefers-color-scheme to dark.

I figured that it would also be nice if the user's preference were saved so it could be applied if they came back to the page later or visited other pages.

In nup, a music player app that I wrote, I had already written code to support storing user preferences using localStorage. I used it (via the config object below) to set a data-theme attribute on the <document> element that is initially based on prefers-color-scheme but can be overriden by the preference:

const darkMediaQuery = '(prefers-color-scheme: dark)';
const updateTheme = () => {
  let dark = false;
  switch (config.get(Pref.THEME)) {
    case Theme.AUTO:
      dark = window.matchMedia(darkMediaQuery).matches;
      break;
    case Theme.LIGHT:
      break;
    case Theme.DARK:
      dark = true;
      break;
  }
  if (dark) document.documentElement.setAttribute('data-theme', 'dark');
  else document.documentElement.removeAttribute('data-theme');
};

config.addCallback((k, _) => k === Pref.THEME && updateTheme());
window.matchMedia(darkMediaQuery).addListener((e) => updateTheme());
updateTheme();

In the stylesheet, I set CSS variables based on the data-theme attribute and used them to style the app:

:root {
  --bg-color: #fff;
  --text-color: #000;
}
[data-theme='dark'] {
  --bg-color: #222;
  --text-color: #ccc;
}

body {
  background-color: var(--bg-color);
  color: var(--text-color);
}

This worked well, but I noticed that I would often briefly see a jarring white page when loading app with the dark theme enabled due to the code that sets the data-theme attribute running asynchronously. To work around this, I duplicated updateTheme's logic in a <script> tag at the top of the <body> element so that data-theme would already be set when the page is first rendered:

<body>
  <script>
    (() => {
      const config = localStorage.getItem('config');
      const pref = JSON.parse(config ?? '{}').theme ?? 0; // Pref.THEME
      if (
        pref === 2 || // Theme.DARK
        (pref === 0 && window.matchMedia('(prefers-color-scheme: dark)').matches) // Theme.AUTO
      ) {
        document.documentElement.setAttribute('data-theme', 'dark');
      }
    })();
  </script>
  <!-- ... -->
</body>

Dumbing it down for older browsers

For the static website that you're reading now, I want to maintain broader support for old browsers: most importantly, I'm not using CSS variables. I decided to instead dynamically add a dark CSS class to the <body> element to enable the dark theme.

const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');

// Adds or remove the 'dark' class from document.body per localStorage and
// prefers-color-scheme. If |toggle| is truthy, toggles the current value and
// saves the updated value to localStorage.
function applyTheme(toggle) {
  const hasStorage = typeof Storage !== 'undefined';
  let dark = false;
  if (toggle) {
    dark = !document.body.classList.contains('dark');
    if (hasStorage) localStorage.setItem('theme', dark ? 'dark' : 'light');
  } else {
    const saved = hasStorage ? localStorage.getItem('theme') : null;
    dark = saved !== null ? saved === 'dark' : darkQuery.matches;
  }
  dark
    ? document.body.classList.add('dark')
    : document.body.classList.remove('dark');
}

// Toggle the theme when an icon is clicked.
document.getElementById('dark-icon')
  .addEventListener('click', () => applyTheme(true));

In the stylesheet, I can then style elements appropriately:

body {
  color-scheme: light;
  background-color: white;
  color: black;
}
body.dark {
  color-scheme: dark;
  background-color: black;
  color: white;
}

a {
  color: navy;
}
body.dark a {
  color: yellow;
}

(In actuality, I use Sass to make writing CSS less tedious.)

Inverting icon images

My website uses small black monochrome images as icons in various places. I was initially stumped about how to swap in white versions of these images for the dark theme: I didn't want to use JavaScript to update all of the <img> elements' src attributes when toggling the theme, and using the background-image CSS property (which can be overriden in the stylesheet) also seemed like it would be a disaster.

I eventually realized that I could use the filter CSS property to easily invert the icons in my <header> element from black to a brighter, almost-white color:

body.dark header img {
  filter: invert(1) brightness(0.9);
}

Applying themes to iframes

My website uses iframes to display subpages containing interactive graphs and maps (see e.g. glucometer.html and pullups.html), and I wanted the framed pages to be styled in concordance with the main page. To do this, I included the above applyTheme code and the following in the framed HTML documents:

applyTheme();
darkQuery.addEventListener('change', () => applyTheme());
window.addEventListener('storage', () => applyTheme());

This way, the framed page uses the same logic as its outer page to select the initial theme. Calling applyTheme() in response to storage events makes the framed page automatically update its theme when the user toggles the theme on the outer page, and I'm able to style framed documents using the same body.dark selector that I use for outer pages.

After doing this, I noticed that when using the dark theme, iframes were rendered as ugly white boxes before they loaded rather than being transparent. I eventually ended up at chromium issue 1150352, where I learned that this is intentionally done in cases where the color-scheme CSS property of the <iframe> element and of the framed document's root element don't match. It wasn't straightforward for me for set color-scheme on the root element, so I instead used this Stack Overflow answer's approach of hardcoding the property on <iframe> elements to override the behavior:

iframe {
  color-scheme: normal;
}

This post by Florens Verschelde goes into much more detail about the interaction between dark mode and transparent iframes.

Supporting dark mode in AMP pages

My website provides an AMP version of each page (as described in excruciating detail in the AMP conversion page).

For the most part, AMP discourages custom JavaScript (although I think it's now supported to some degree via the amp-script tag and actions and events).

I haven't been able to find much documentation about it, but as discussed in amphtml issue 20951, a toggleTheme action was added to support switching between the dark and light themes:

<amp-img role="button" on="tap:AMP.toggleTheme()" etc ></amp-img>

By default, AMP sets an amp-dark-mode class on the <body> element when the dark theme should be used, but I used the data-prefers-dark-mode-class attribute to tell it to instead set the same dark class that I use for non-AMP pages:

<body data-prefers-dark-mode-class="dark">

As a bonus, AMP appears to save the user's selection after they toggle dark mode on or off, so they'll see the same theme if they reload the page later.

I noticed that iframe-embedded documents were throwing errors when they tried to call my applyTheme function:

Uncaught DOMException: Failed to read the 'localStorage' property from 'Window': The document is sandboxed and lacks the 'allow-same-origin' flag.

As described in my AMP conversion page, AMP doesn't permit setting allow-same-origin on iframes. Inspired by this Stack Overflow answer, I added a check for document.domain to the top of applyTheme:

function applyTheme(toggle) {
  // AMP iframes can't use allow-same-origin since they might be served from the
  // cache. Check document.domain to determine if we're sandboxed, which
  // prevents us from accessing localStorage: https://stackoverflow.com/a/34073811
  //
  // Just give up and use the light theme in this case, since we won't be able
  // to tell if the user toggles the theme, and using the dark theme in an
  // iframe while the rest of the page is using the light theme looks weird.
  if (!document.domain) return;

  const hasStorage = typeof Storage !== 'undefined';
  ...

This means that iframed documents always use the light theme when embedded in AMP pages, which isn't great, but I couldn't come up with any way to let them know about theme changes in the AMP page.

Styling embedded Google Maps

Some of my pages use the Google Maps JavaScript API. The default map style uses bright colors, but I was able to use the Google Maps Style Wizard to create a JSON style based on the "Night" theme that worked well with my site's dark theme. It was then straightforward to use the API to dynamically update the map's style to match the theme:

// Returns the 'styles' value for google.maps.MapOptions.
function getStyles() {
  // Just use the default light style if the dark theme isn't being used.
  if (!document.body.classList.contains('dark')) return undefined;

  return [
    {
      elementType: 'geometry',
      stylers: [{ color: '#242f3e' }],
    },
    {
      elementType: 'labels.text.fill',
      stylers: [{ color: '#746855' }],
    },
    // ...
  ];
}

// Sets the 'dark' class on the <body> element and updates the map's styles.
function updateStyle() {
  applyTheme();
  map.setOptions({ styles: getStyles() });
}

window.addEventListener('DOMContentLoaded', () => {
  darkQuery.addEventListener('change', () => updateStyle());
  window.addEventListener('storage', () => updateStyle());

  // [omit lengthy Maps API initialization code]...
  updateStyle();
});

Chroma syntax highlighting

I use the Chroma Go library to perform syntax highlighting on code snippets included in pages. The Chroma style gallery shows that many different light-on-dark styles are available, but I needed to find a way to automatically switch the Chroma style to match the page's theme.

Luckily, the Chroma HTML formatter's WriteCSS method can be used to emit a stylesheet containing the CSS classes for a style. I wrote a function that can mangle the selectors to add a body.dark prefix to the dark style:

import (
    "github.com/alecthomas/chroma"
    "github.com/alecthomas/chroma/formatters/html"
    "github.com/alecthomas/chroma/styles"
)

var chromaFmt = html.New(html.WithClasses(true))

// getCodeCSS generates CSS class definitions for the named Chroma style.
// If selPrefix is non-empty (e.g. "body.dark "), it will be prefixed to each selector.
func getCodeCSS(style, selPrefix string) (string, error) {
    cs := styles.Get(style)
    if cs == nil {
        return "", fmt.Errorf("couldn't find chroma style %q", style)
    }
    var b bytes.Buffer
    if err := chromaFmt.WriteCSS(&b, cs); err != nil {
        return "", err
    }
    s := b.String()
    if selPrefix != "" {
        // The unminified CSS consists of lines like
        // "/* Background */ .chroma { color: #f8f8f2; ...".
        s = strings.ReplaceAll(s, " .chroma ", selPrefix+" .chroma ")
    }
    return s, nil
}

I generate CSS for both light and dark styles, which I then include in the page's stylesheet:

lightCSS, _ := getCodeCSS("solarized-light", "")
darkCSS, _ := getCodeCSS("solarized-dark", "body.dark ")

The generated CSS isn't small (almost 5 KB minified), but it works.


  1. Edge added support in 2020. [return]