Calvin's Blog

Making A Site Theme Toggle Web Component

∙ web∙ web-components
article meme

Introduction

I created a web component for toggling the theme on a web site. If you would like to see it in action see this CodePen.

A usable NPM package is located here.

The code is also in a public Github repo here.

The Code

The code for this web component is all inside of a single ts file (js if you build it).

At the top of the file we have some code to add the <template> element to the page that is used to create new instances of the ce-themetoggle. There is a safe guard around this code to avoid the template being added more than

/**
 * Like a C include guard. 
 * If the element is already registered we don't want to try and register it again...
 */
if (!document.getElementById("ce-themetoggle-template")) {
    const template = document.createElement('template')
    template.id = "ce-themetoggle-template"
    const templateText =
        `
        <style>
            label.visual-toggle {
                --toggle-height: 1.5rem;
                --toggle-width: calc(var(--toggle-height) * (2 / 3 + 1));
                --lr-padding: calc(var(--toggle-width) * 0.05);
                --border-width: calc(var(--toggle-height) * 0.1);
                --border-radius: 1000px;
                --toggle-color: #000;
                width: var(--toggle-width);
                height: var(--toggle-height);
                position: relative;
                border: var(--border-width) solid var(--toggle-color);
                border-radius: var(--border-radius);
                display: flex;
                flex-direction: row;
                align-items: center;
                justify-content: flex-start;
                box-sizing: content-box;
                padding-left: var(--lr-padding);
                padding-right: var(--lr-padding);
            }
        
            label.visual-toggle[data-current-theme="dark"] {
                --toggle-color: #FFF;
            }
        
            span.toggle-switch {
                --left: 0.75;
                --right: 0.9;
                --switch-height: calc(var(--toggle-height) * 0.70);
                --switch-width: var(--switch-height);
                --toggled-pos: calc(var(--toggle-width) - var(--switch-width));
                height: var(--switch-height);
                width: var(--switch-width);
                background-color: var(--toggle-color);
                box-sizing: border-box;
                border-radius: var(--border-radius);
                transition: all 0.5s linear;
            }
        
            input.theme-toggle:checked + span.toggle-switch {
                transform: translateX(var(--toggled-pos));
                background-color: var(--toggle-color);
            }
        
            input.theme-toggle[type="checkbox"] {
                display: none;
            }
        </style>
        <label class="visual-toggle">
            <input type="checkbox" class="theme-toggle" />
            <span class="toggle-switch"> </span>
        </label>
        `;
    template.innerHTML = templateText;
    document.body.appendChild(template);
}

Next we have an additional guard against declaring and registering the component multiple times. Then we have our class declaration and constructor.

/**
 * Like a C include guard. 
 * If the element is already registered we don't want to try and register it again...
 */
if (!customElements.get("ce-themetoggle")) {
    class ThemeToggle extends HTMLElement {

        private targetSelectorAttr: string;
        private currentThemeAttr: string;
        private lightColorAttr: string;
        private darkColorAttr: string;
        private toggleHeightAttr: string;

        private checkboxElement: HTMLInputElement | null;
        private toggleContainerElement: HTMLLabelElement | null;
        private toggleKnobElement: HTMLSpanElement | null;

        constructor() {
            super();
            this.targetSelectorAttr = "body";
            this.currentThemeAttr = ThemeToggle.defaultTheme;
            this.lightColorAttr = "#000000";
            this.darkColorAttr = "#FFFFFF";
            this.toggleHeightAttr = "1.5rem";
            this.checkboxElement = null;
            this.toggleContainerElement = null;
            this.toggleKnobElement = null;
            const template = document.getElementById("ce-themetoggle-template");
            const templateContent = (template as HTMLTemplateElement).content;

            this.attachShadow(
                {
                    mode: "open",
                }
            ).appendChild(templateContent.cloneNode(true));
        }

Then we have a series of static properties in our class to avoid duplication of constant strings and other useful stuff.


/**
 * This is a list of attributes that when modified will trigger the attributeChangedCallback
 */
static get observedAttributes() {
    return [
        ThemeToggle.targetSelectorAttrName,
        ThemeToggle.currentThemeAttrName,
        ThemeToggle.darkColorAttrName,
        ThemeToggle.lightColorAttrName,
        ThemeToggle.toggleHeightAttrName,
    ];
}

/**
 * The attribute name that is used to specify the target of the theme toggle component.
 */
static get targetSelectorAttrName() {
    return "data-target-selector";
}

/**
 * The attribute name that is used to specify as an override to set the current theme on load.
 */
static get currentThemeAttrName() {
    return "data-current-theme";
}

/**
 * The attribute name that is used to specify the color to apply to this toggle when light mode is applied.
 */
static get lightColorAttrName() {
    return "data-light-color";
}

/**
 * The attribute name that is used to specify the  color to apply to this toggle when dark mode is applied.
 */
static get darkColorAttrName() {
    return "data-dark-color";
}

/**
 * The attribute name that is used to specify the height of this component on screen.
 * All other dimensions like the size of the toggle knob or event the width of the whole component are calculated based on this value.
 * A default is provided in the HTML template.
 */
static get toggleHeightAttrName() {
    return "data-toggle-height";
}

/**
 * The name use to represent dark mode. Really just here to avoid typos ;-)
 */
static get darkThemeName() {
    return "dark";
}

/**
 * The name use to represent light mode. Really just here to avoid typos ;-)
 */
static get lightThemeName() {
    return "light";
}

/**
 * The name of the value in local storage to save theme preference.
 */
private static get themePreferenceName() {
    return "ce-themetoggle-theme-preference";
}

/**
 * Tells us the default theme.
 * TODO: make this configurable...
 */
static get defaultTheme() {
    return ThemeToggle.darkThemeName
}

/**
 * Tells us the alternate theme.
 * TODO: derive based on default theme.
 */
static get secondaryTheme() {
    return ThemeToggle.lightThemeName
}

Next we have our HTML Element lifecycle callbacks.


/**
 * Handles setting everything up when the node is attached to the dom.
 */
connectedCallback() {
    // handle checkbox stuff
    this.checkboxElement = (this.shadowRoot?.querySelector("input.theme-toggle") as HTMLInputElement | null);
    if (this.checkboxElement === null) {
        throw new Error("ce-themetoggle: some how the check box element from the template does not exist...");
    }
    this.toggleContainerElement = (this.shadowRoot?.querySelector("label.visual-toggle") as HTMLLabelElement | null);
    if (this.toggleContainerElement === null) {
        throw new Error("ce-themetoggle: some how the toggle container element from the template does not exist...");
    }
    this.toggleKnobElement = (this.shadowRoot?.querySelector("span.toggle-switch") as HTMLSpanElement | null);
    if (this.toggleKnobElement === null) {
        throw new Error("ce-themetoggle: some how the toggle knob element from the template does not exist...");
    }
    this.checkboxElement.addEventListener("change", (e) => {
        const checked = (e.target as HTMLInputElement).checked;
        this.handleThemeChange("", checked ? ThemeToggle.defaultTheme : ThemeToggle.secondaryTheme, false)
    });

    this.targetSelectorAttr = this.getAttribute(ThemeToggle.targetSelectorAttrName) ?? "body";
    const currentThemePreference = this.getThemePreference()
    if (this.hasAttribute(ThemeToggle.currentThemeAttrName)) {
        // Get preference from specified attribute from this element.
        this.currentThemeAttr = this.getAttribute(ThemeToggle.currentThemeAttrName) ?? ThemeToggle.darkThemeName;
    } else if (currentThemePreference) {
        // Get preference from local storage value if present.
        this.currentThemeAttr = currentThemePreference;
    } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
        // No specific theme provided and not saved preference found. See if system preference is light mode and if it is use that.
        this.currentThemeAttr = ThemeToggle.lightThemeName;
    } else {
        // Catch all is dark mode because it is superior.
        this.currentThemeAttr = ThemeToggle.darkThemeName;
    }
    if (this.currentThemeAttr == ThemeToggle.defaultTheme) {
        // TODO: Should this be true if the secondary theme is selected?
        this.checkboxElement.checked = true;
    }
    this.handleTargetChange("", this.targetSelectorAttr, true);
}

/**
 * This is triggered any time an attribute is added / changed if it is in the static array observedAttributes in this class.
 * @param name The name of the attribute
 * @param oldValue The previous value of the attribute
 * @param newValue The new value of the attribute
 */
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
    console.log('Toggle theme element attributes changed.', { name, oldValue, newValue });
    switch (name) {
        case ThemeToggle.toggleHeightAttrName:
            this.toggleHeightAttr = newValue;
            this.toggleContainerElement?.style.setProperty("--toggle-height", this.toggleHeightAttr);
            break;
        case ThemeToggle.lightColorAttrName:
            this.lightColorAttr = newValue;
            if (this.currentThemeAttr === ThemeToggle.lightThemeName) {
                this.toggleContainerElement?.style.setProperty("--toggle-color", this.lightColorAttr);
            }
            break;
        case ThemeToggle.darkColorAttrName:
            this.darkColorAttr = newValue;
            if (this.currentThemeAttr === ThemeToggle.darkThemeName) {
                this.toggleContainerElement?.style.setProperty("--toggle-color", this.darkColorAttr);
            }
            break;
        case ThemeToggle.targetSelectorAttrName:
            this.handleTargetChange(oldValue, newValue);
            break;
        case ThemeToggle.currentThemeAttrName:
            this.handleThemeChange(oldValue, newValue)
            break;
    }
}

Here we have the methods we use to interact with the browsers local storage.

/**
 * Sets the users theme preference in the users local storage.
 * @param theme The theme to set as the preference in the users local storage.
 */
private setThemePreference(theme: string) {
    window.localStorage.setItem(ThemeToggle.themePreferenceName, theme)
}

/**
 * Gets the current theme preference from local storage if set. Otherwise returns null.
 */
private getThemePreference(): string | null {
    return window.localStorage.getItem(ThemeToggle.themePreferenceName)
}

Now we get to the specific handlers that handle when the theme or target of the toggle is changed.


    /**
     * Handles the target selector change. First it removes the theme attribute from the current target, then applies to the new target.
     * @param oldValue The old selector. Used to get the old target and remove our theme attribute
     * @param newValue The new target to apply the theme to
     * @param force Used when component first mounts to bypass the repeat theme check
     * @returns 
     */
    private handleTargetChange(oldValue: string, newValue: string, force: boolean = false) {
        if (this.targetSelectorAttr === newValue && !force) {
            return;
        }
        if (oldValue) {
            const oldTargets = this.querySelectorAll(oldValue);
            for (const ot of oldTargets) {
                ot.removeAttribute(ThemeToggle.targetSelectorAttrName);
            }
        }
        this.targetSelectorAttr = newValue;
        this.handleThemeChange("", this.currentThemeAttr, true)
        this.setAttribute(ThemeToggle.targetSelectorAttrName, newValue);
    }

    /**
     * Handler for when the theme changes. Updates targets with the new theme
     * @param _oldValue Not used. thought I might log the old value... I have not...
     * @param newValue The new theme value.
     * @param force Used when component first mounts to bypass the repeat theme check
     * @returns {undefined}
     */
    private handleThemeChange(_oldValue: string, newValue: string, force: boolean = false) {
        if (this.currentThemeAttr === newValue && !force) {
            return;
        }
        const targets = document.querySelectorAll(this.targetSelectorAttr)
        let normalizedValue = "";
        switch (newValue?.toLowerCase()) {
            case ThemeToggle.lightThemeName:
                normalizedValue = ThemeToggle.lightThemeName;
                break;
            case ThemeToggle.darkThemeName:
                normalizedValue = ThemeToggle.darkThemeName;
                break;
            default:
                normalizedValue = ThemeToggle.darkThemeName;
                break;
        }
        for (const t of targets) {
            t.setAttribute(ThemeToggle.currentThemeAttrName, normalizedValue);
            this.currentThemeAttr = normalizedValue;
        }
        if (!force) {
            this.setThemePreference(this.currentThemeAttr);
        }
        this.toggleContainerElement?.style.setProperty("--toggle-color", this.currentThemeAttr == ThemeToggle.darkThemeName ? this.darkColorAttr : this.lightColorAttr);
        this.setAttribute(ThemeToggle.currentThemeAttrName, normalizedValue);
    }
}

Finally we register our web component with the the windows customElements property

    // Register the web component so we can use it.
    customElements.define("ce-themetoggle", ThemeToggle);
}

It had been a while since I had played with the web components standards. I really like the idea of making components using web standards as opposed to using frameworks for everything. Admittedly, I say that having been a heavy React and Angular user over the years.

How To Use It

I have an example page here.

If you are using a bundler that supports typescript, you can also import the typescript file directly:

import "ce-web-components/components/ce-themetoggle/ce-themetoggle";

If you want to import the build JS directly you can do this:

<!-- preferable below your body content -->
<script type="text/javascript" src="your_location/ce-themetoggle.js"></script>

The file registers its self and does not export anything, so all you need is the import.

With some CSS like this:

:root {
    --light-color: #000000;
    --light-accent-color: #826f10;
    --light-off-color: #e5e5e5;
    --light-background-color: #ffffff;

    --dark-color: #ffffff;
    --dark-accent-color: #fcda1f;
    --dark-off-color: #24292e;
    --dark-background-color: #000000;
}

/* We default to dark mode... Because we are civilized */
[data-current-theme="dark"] {
    --color: var(--dark-color);
    --accent-color: var(--dark-accent-color);
    --off-color: var(--dark-off-color);
    --background-color: var(--dark-background-color);
}

[data-current-theme="light"] {
    --color: var(--light-color);
    --accent-color: var(--light-accent-color);
    --off-color: var(--light-off-color);
    --background-color: var(--light-background-color);
}

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

span {
    color: var(--accent-color);
}

Will have the toggle working like a charm!

For a complete example see the CodePen or the example in the GitHub repo