Making A Site Theme Toggle Web Component
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