Page Theme Switcher

Overview

This is how I'm doing my color mode/theme/scheme switcher:

  1. Create individual CSS Custom Property/Variables for each theme. I'm using three ('light', 'dark', and 'high-contrast'), but you can make as many as you'd like.
  2. Create light and dark mode variables that point to the individual variables for each theme. (Nothing is done with 'high-contrast' at this stage as it's only set by JavaScript).
  3. Create element styles that call `*--switch` variables that don't initially exist with fallbacks to the mode specific variables.
  4. Add a script tag to the head of the document that uses JavaScript (if it's available) to populate the `*--switch` variables based of user selections on the page.

The result is a progressively enhanced theme setup. JavaScript is used to set the theme if everything is functional. Values fall back to the `prefers-color-scheme` if JavaScript is either unavailable or if no manual preference has been set. If the `prefers-color-scheme` value isn't found the styles fall back to the non-customized version.

Live Example

Details

The CSS

My style sheet breaks down into four sections:

  1. The base color variables for each theme

          :root {
            --light--link: goldenrod;
            --light--background: slategray;
            --light--color: black;
            --dark--link: blue;
            --dark--background: black;
            --dark--color: white;
            --high-contrast--link: green;
            --high-contrast--background: black;
            --high-contrast--color: yellow;
          }
  2. Default variables set to use the primary `--light-*`` variables

          :root {
            --link: var(--light--link);
            --background: var(--light--background);
            --color: var(--light--color);
          }
  3. Dark mode variables set to use the primary `--dark-*`` variables

          @media (prefers-color-scheme: dark) {
            :root {
              --link: var(--dark--link);
              --background: var(--dark--background);
              --color: var(--dark--color);
            }
          }
  4. Page element styles that set to use `*--switch` variables (that don't exist yet) with fallbacks to the basic variable names.

          a {
            color: var(--link--switch, var(--link));
          }
    
          body {
            background-color: 
              var(--background--switch, var(--background));
            color: var(--color--switch, var(--color));
          }

The JavaScript

This script tag goes directly in the <head> of the page directly after the call for the stylesheet.

    <script>
      function updateStyles(theme) {
        if (theme) {
          const styleKeys = [`background`, `color`, `link`];
          styleKeys.forEach((styleKey) => {
            const key = `--${styleKey}--switch`;
            if (theme === "auto") {
              document.documentElement.style.removeProperty(key);
            } else {
              const value = `var(--${theme}--${styleKey})`;
              document.documentElement.style.setProperty(
                key, value
              );
            }
          });
          localStorage.setItem("theme", theme);
        }
      }
      updateStyles(localStorage.getItem("theme"));
    </script>

The first thing the script does is make the updateStyles() function. The styleKeys array inside it defines the list of styles to update based off the naming the naming convention that maps the base colors to the *--switch versions.

The function checks to make sure a theme value is passed then uses it to determine what happens next. It it's auto, the function removes the custom *--switch properties. Otherwise, it adds them with the given theme name.

After the properties, the function also updates the localStorage value with the theme. Doing that means all the storage checking and setting happens in the same place. No need to mess with it in whatever scripting is used for the interface.

After the function is set up, the script makes an initial call to it with the current localStorage value for `theme`.

The Interface

The last step is making an interface on the page. I'm using bitty. Other approaches would work just as well. The only real requirement is calling the updateStyles() function with the name of the theme.

bitty approach

Since the theme switcher doesn't work without JavaScript. So, I use this empty HTML element that gets populated directly by it. That way, the HTML radio buttons don't show up on the page if the JavaScript fails for some reason.

<div data-location="area-name" data-receive="themeSwitcher"></div>

And then this in the `/scripts/theme-switcher.js` file:

const themes = [
  ["auto", "Auto"],
  ["light", "Light"],
  ["dark", "Dark"],
  ["high-contrast", "High Contrast"],
];

const tmpl = `<div><label>
  <input type="radio" 
    name="mode-LOCATION" 
    value="KEY" 
    data-send="changeTheme" 
    data-receive="syncChecked" 
    CHECKED
/>NAME</label></div>`;

export default class {
  bittyReady() {
    this.api.setProp("--load-hider", "1");
  }

  changeTheme(event, _el) {
    if (event.type === "input") {
      updateStyles(event.target.value);
      this.api.trigger("syncChecked");
    }
  }

  getCurrentTheme() {
    let current = localStorage.getItem("theme");
    if (current) {
      return current;
    } else {
      return "auto";
    }
  }

  async themeSwitcher(_event, el) {
    for (let theme of themes) {
      const checked = this.getCurrentTheme() === theme[0] ? "checked" : "";
      const subs = [
        ["KEY", theme[0]],
        ["NAME", theme[1]],
        ["LOCATION", el.dataset.location],
        ["CHECKED", checked]
      ];
      const option = this.api.makeElement(tmpl, subs);
      await el.appendChild(option);
    }
  }

  syncChecked(_event, el) {
    if (el.value === this.getCurrentTheme()) {
      el.checked = true;
    } else {
      el.checked = false;
    }
  }
};

Full Example

Here's a full HTML page to show how everything looks when combined that you can check out here.

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Theme Switcher Example</title>
    <meta charset="UTF-8" />
    <meta 
      name="viewport" 
      content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="/styles/example-styles.css" />
    <script>
      function updateStyles(theme) {
        if (theme) {
          const styleKeys = [`background`, `color`, `link`];
          styleKeys.forEach((styleKey) => {
            const key = `--${styleKey}--switch`;
            if (theme === "auto") {
              document.documentElement.style.removeProperty(key);
            } else {
              const value = `var(--${theme}--${styleKey})`;
              document.documentElement.style.setProperty(
                key, value
              );
            }
          });
          localStorage.setItem("theme", theme);
        }
      }
      updateStyles(localStorage.getItem("theme"));
    </script>
  </head>
  <body>
    <bitty-5-1 
      data-connect="/scripts/theme-switcher.js"
      data-send="themeSwitcher">

      <a href="/">Home page</a>
      <h1>Theme Switcher Example</h1>
      <div data-location="bottom" data-receive="themeSwitcher"></div>

    </bitty-5-1>
    <script src="/components/bitty-5.1.0.min.js"></script>
  </body>
</html>

The `/styles/example-styles.css` file looks like this:

:root {
  --light--link: goldenrod;
  --light--background: slategray;
  --light--color: black;
  --dark--link: blue;
  --dark--background: black;
  --dark--color: white;
  --high-contrast--link: green;
  --high-contrast--background: black;
  --high-contrast--color: yellow;
}

:root {
  --link: var(--light--link);
  --background: var(--light--background);
  --color: var(--light--color);
}

@media (prefers-color-scheme: dark) {
  :root {
    --link: var(--dark--link);
    --background: var(--dark--background);
    --color: var(--dark--color);
  }
}

a {
  color: var(--link--switch, var(--link));
}

body {
  background-color: 
    var(--background--switch, var(--background));
  color: var(--color--switch, var(--color));
}

Endnotes

debug