Jonathan Batchelor

Plug and Play theme switcher using WebC

When I started dabbling with 11ty (now Build Awesome) I quickly found the fantastic resource of 11tybundle by Bob Monsour. I noticed that it had a pretty nifty theme switching toggle button. I also read about a new type of component that Zach Leatherman (the creator of 11ty) had built - WebC. This led me to wonder if a theme switching component could be made in WebC that could be easily dropped into an 11ty site and be up and running with minimal configuration. Bob was happy for me to use the code from 11tybundle (written by Damian Walsh) to give it a go...

WebC

A WebC component is a mostly self-contained package of HTML, CSS and Javascript. WebC support currently needs enabling in 11ty/Build Awesome - the 11ty documentation is the best place to go for that information (Build Awesome WebC documentation). Once done, I drop my entire theme-switcher.webc file into the _components folder and can then import into my base template (details below). Note: This article describes my component as it is at the time of writing, but it may have changed since then - feel free to check out the page source and I'll try and make sure the webc file linked at the bottom is the current version.

Now we'll look at the structure of the .webc file.
(A download link for the full file is at the bottom of the page)

HTML

We start with a form for the switcher itself:

<form id="theme-switcher-form" class="hidden">
  <fieldset>
    <legend class="visually-hidden">Theme</legend>
    <label>
      <input type="radio" name="theme" value="auto" />
      <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" eleventy:ignore>
        <use href="#theme-switcher-icon-sun-moon"></use>
      </svg>
      Auto</label>
    <label>
      <input type="radio" name="theme" value="light" />
      <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" eleventy:ignore>
        <use href="#theme-switcher-icon-sun"></use>
      </svg>
      Light</label>
    <label>
      <input type="radio" name="theme" value="dark" />
      <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true" eleventy:ignore>
        <use href="#theme-switcher-icon-moon"></use>
      </svg>
      Dark</label>
  </fieldset>
</form>

Sprite Sheet

To keep everything as self contained as possible, we include a small spritesheet with icons that can be re-used. I've pulled sun-moon, sun and moon from the Lucide project:

<svg width="0" height="0" aria-hidden="true" id="theme-switcher-icon-sheet" viewbox="0 0 24 24">
  <defs>
    <g id="theme-switcher-icon-sun-moon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun-moon preview-icon">
      <path d="M12 2v2"/><path d="M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715"/><path d="M16 12a4 4 0 0 0-4-4"/><path d="m19 5-1.256 1.256"/><path d="M20 12h2"/>
    </g>
    <g id="theme-switcher-icon-moon" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
      <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79" />
    </g>
    <g id="theme-switcher-icon-sun" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
      <circle cx="12" cy="12" r="5"></circle>
      <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
    </g>
  </defs>
</svg>

CSS

Following the HTML, some basic CSS styling is added with a few CSS variables with fallback defaults to enable styling in the main stylesheet: --theme-switcher-background-color, --theme-switcher-border-color, --theme-switcher-hover-color and --theme-switcher-hover-text-color. More variables could be broken out, but this works for me.

<style webc:scoped>
  .visually-hidden {
    border: 0;
    clip: rect(0 0 0 0);
    height: 0;
    margin: 0;
    overflow: hidden;
    padding: 0;
    position: absolute;
    white-space: nowrap;
    width: 1px;
  }

  .hidden,
  #theme-switcher-icon-sheet {
    display: none;
  }

  form {
    background: var(--theme-switcher-background-color, white);
  }

  fieldset {
    border: none;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2xs, 2rem);
    justify-content: center;
    transition:
      opacity 150ms ease-in-out 25ms,
      transform 150ms ease-in-out 25ms;
  }

  [type="radio"] {
    position: absolute;
    opacity: 0;
    width: 0;
    height: 0;
  }

  label {
    display: flex;
    gap: var(--space-3xs, 1rem);
    align-items: center;
    justify-content: center;
    padding: var(--space-2xs, 2rem) var(--space-xs, 3rem);
    cursor: pointer;
    width: max-content;
    border: 1px solid var(--theme-switcher-border-color, gray);
    border-radius: 999px;
    transition-property: background-color;
    transition-duration: 150ms;
    transition-timing-function: ease-in-out;

    &:hover {
      background: var(--theme-switcher-hover-color, lightyellow);
      color: var(--theme-switcher-hover-text-color, gray);
    }
  }
</style>

Javascript

Finally the javascript is based on that from 11tybundle with just a few tweaks to choose the appropriate icons from the sprite sheet etc.

<script>
  function setInitialTheme() {
    const savedTheme = localStorage.getItem("theme");
    let effectiveTheme;
    if (savedTheme && savedTheme !== "auto") {
      effectiveTheme = savedTheme;
    } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
      effectiveTheme = "dark";
    } else {
      effectiveTheme = "light";
    }
    document.documentElement.setAttribute("data-theme", effectiveTheme);
  }
  setInitialTheme();

  function updateThemeToggleButton(theme) {
    const btn = document.querySelector(
      'button.theme-switcher[aria-controls="theme"]',
    );
    if (!btn) return;
    const use = btn.querySelector("use");
    if (!use) { console.log("can't access btn.querySelector"); return;}
    if (theme === "dark") {
      use.setAttribute("href", "#theme-switcher-icon-moon");
    } else if (theme === "light") {
      use.setAttribute("href", "#theme-switcher-icon-sun");
    } else {
      use.setAttribute("href", "#theme-switcher-icon-sun-moon");
    }
  }

  document.addEventListener("DOMContentLoaded", () => {
    const savedTheme = localStorage.getItem("theme");
    updateThemeToggleButton(savedTheme || "auto");

    const themeButton = document.querySelector(
      'button.theme-switcher[aria-controls="theme"]',
    );
    const themeForm = document.getElementById("theme-switcher-form");
    if (!themeForm) return;
    const themeRadios = themeForm.querySelectorAll('input[type="radio"]');

    function updateTheme(theme) {
      if (theme === "auto") {
        localStorage.setItem("theme", "auto");
        const prefersDark = window.matchMedia(
          "(prefers-color-scheme: dark)",
        ).matches;
        document.documentElement.setAttribute(
          "data-theme",
          prefersDark ? "dark" : "light",
        );
      } else {
        document.documentElement.setAttribute("data-theme", theme);
        localStorage.setItem("theme", theme);
      }
      updateThemeToggleButton(theme);
    }

    function initializeControls() {
      const savedTheme = localStorage.getItem("theme");
      if (savedTheme) {
        const el =
          themeForm.querySelector(`input[value="${savedTheme}"]`) ||
          themeForm.querySelector('input[value="auto"]');
        if (el) el.checked = true;
      } else {
        const el = themeForm.querySelector('input[value="auto"]');
        if (el) el.checked = true;
      }
    }

    themeButton.addEventListener("click", () => {
      const themeForm = document.getElementById("theme-switcher-form");
      themeForm.classList.toggle("hidden");
      themeForm.classList.toggle("visible");

      const expanded = themeButton.getAttribute("aria-expanded") === "true";
      themeButton.setAttribute("aria-expanded", String(!expanded));
    });

    themeRadios.forEach((radio) => {
      radio.addEventListener("change", (e) => {
        updateTheme(e.target.value);
      });
      radio.addEventListener("click", () => {
        const themeButton = document.querySelector(
          'button.theme-switcher[aria-controls="theme"]',
        );
        if (themeButton) {
          themeButton.click();
        }
      });
    });

    const mql = window.matchMedia("(prefers-color-scheme: dark)");
    const mqlHandler = (e) => {
      const isAuto = themeForm.querySelector('input[value="auto"]')?.checked;
      const saved = localStorage.getItem("theme");
      if (isAuto || saved === "auto") {
        document.documentElement.setAttribute(
          "data-theme",
          e.matches ? "dark" : "light",
        );
        updateThemeToggleButton("auto");
      }
    };
    if (typeof mql.addEventListener === "function") {
      mql.addEventListener("change", mqlHandler);
    } else if (typeof mql.addListener === "function") {
      mql.addListener(mqlHandler);
    }

    initializeControls();
  });
</script>

Adding to the site template

Once webc is set up and the .webc file is added to the 11ty install, you can include the component in your site's main template.

First of all, add a <button> to trigger the theme switch:

<button class="theme-switcher" aria-controls="theme" data-target="theme" aria-label="Theme settings">
    <svg class="icon" height="24px" width="24px" viewBox="0 0 24 24"><use href="#theme-switcher-icon-sun-moon"></svg>
</button>

This can use an icon from the theme switcher's sprite sheet as above.

Then include the theme switcher itself - I am currently using Liquid (but it's the same for Nunjucks) for my templates and after installing the RenderPlugin in the 11ty configuration, you can include it in your template:

{% renderTemplate "webc" %}<theme-switcher></theme-switcher>{% endrenderTemplate %}

This can be placed wherever you need it, but I place it immediately after my button within the <li> element, meaning I can use my site's stylesheet to position it where I want it:

.primary-navigation ul {
  display: flex;
  gap: 20px;
  list-style: none;
  margin: 0;
  padding: 0;
  align-items: center;
  min-height: 24px;

  li:has(button.theme-switcher){
    position: relative;
  }

  button.theme-switcher {
    border: 0;
    background: transparent;
    color: var(--accent);
    svg { 
        stroke-width: 1.5px; 
    }
  }
}

theme-switcher {
  position: absolute;
  top: 100%;
  --theme-switcher-border-color: var(--border);
  --theme-switcher-hover-color: var(--bg);/* contrast-color(var(--accent-2));*/
  --theme-switcher-hover-text-color: var(--accent-2);
  --theme-switcher-background-color: var(--surface);
  form {
    position: absolute;
    top: 0;
    right: 0;
    padding: var(--space-3xs);
    border: var(--border);
    font-size: var(--step--1);
  }
}

Loading the WebC component's CSS and Javascript

There is one last step - getting the CSS and Javascript from the WebC component into the template. I have added these two lines to my site template's <head> section which pull them in as dynamically created files along with any other styles and js from other components:

<link rel="stylesheet" href="{% getBundleFileUrl 'css' %}"/>
<script src="{% getBundleFileUrl 'js' %}"></script>

You can also have them put directly into the head if you prefer.

Et voilĂ !

A re-usable theme switching component in WebC!

Feel free to download the full file as used on my site from here: theme-switcher.webc (If you get a blank page, view the source!)

Let me know how you're using it or if you have any improvements via my Mastodon!