5.4 KiB
date | title | description | tags | ||||
---|---|---|---|---|---|---|---|
2024-02-27 | Building a theme toggle web component | I (very recently!) added a theme toggle to my site, right up there in the menu. It's a shiny sun or a purple moon depending on your preference. It was a liquid template with some JavaScript sprinkled in. I turned that into a web component. |
|
I (very recently!) added a theme toggle to my site, right up there in the menu. It's a shiny sun or a purple moon depending on your preference. It was a liquid template with some JavaScript sprinkled in. I turned that into a web component.
Much like my now-playing
component, I start out by registering the component template:
const themeToggleTemplate = document.createElement('template')
themeToggleTemplate.innerHTML = `
<button class="theme__toggle">
<span class="light"></span>
<span class="dark"></span>
</button>
`
themeToggleTemplate.id = "theme-toggle-template"
if (!document.getElementById(themeToggleTemplate.id)) document.body.appendChild(themeToggleTemplate)
The template is generated with empty light
and dark
spans so that the clickable element (an icon or text) can be defined when it's implemented. Next, we register the tag name and create our ThemeToggle
class:
class ThemeToggle extends HTMLElement {
static register(tagName) {
if ("customElements" in window) customElements.define(tagName || "theme-toggle", ThemeToggle)
}
My verbose, connectedCallback
handles appending the template and appending theme behavior. The button included in the template is selected and cached and a setTheme
method is defined, allowing the logic contained therein to be reused on load (with a simple boolean argument) and when the theme-toggle
is clicked. It checks where the user prefersDarkScheme
, sets the cached currentTheme
accordingly and adds the appropriate class to the document body. The event listener attached to the theme-toggle
toggles the body class and runs the setTheme
function, skipping the logic that runs on load.
async connectedCallback() {
this.append(this.template)
const btn = this.querySelector('.theme__toggle')
const setTheme = (isOnLoad) => {
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)').matches
const currentTheme = localStorage?.getItem('theme')
let theme
if (!currentTheme) localStorage?.setItem('theme', (prefersDarkScheme ? 'dark' : 'light'))
if (isOnLoad) {
if (currentTheme === 'dark') {
document.body.classList.add('theme__dark')
} else if (currentTheme === 'light') {
document.body.classList.add('theme__light')
} else if (prefersDarkScheme) {
document.body.classList.add('theme__dark')
} else if (!prefersDarkScheme) {
document.body.classList.add('theme__light')
}
}
if (prefersDarkScheme) {
theme = document.body.classList.contains('theme__light') ? 'light' : 'dark'
} else {
theme = document.body.classList.contains('theme__dark') ? 'dark' : 'light'
}
localStorage?.setItem('theme', theme)
}
setTheme(true);
btn.addEventListener('click', () => {
document.body.classList.toggle('theme__light')
document.body.classList.toggle('theme__dark')
setTheme()
})
}
The CSS for this is straightforward and contains a few vars specific to my implementation (related to icon color and SVG stroke-width
):
.theme__toggle {
background: transparent;
padding: 0;
}
.theme__toggle svg {
cursor: pointer;
}
.theme__toggle:hover,
.theme__toggle svg:hover {
stroke-width: var(--stroke-width-bold);
}
.theme__toggle > .light svg { stroke: var(--sun) !important; }
.theme__toggle > .dark svg { stroke: var(--moon) !important; }
.theme__toggle > .light ,
.theme__toggle > .dark {
display: none;
}
.theme__dark .theme__toggle > .light {
display: inline;
}
.theme__dark .theme__toggle > .dark {
display: none;
}
.theme__light .theme__toggle > .light {
display: none;
}
.theme__light .theme__toggle > .dark {
display: inline;
}
The final template that leverages the component looks like this: {% raw %}
<script type="module" src="/assets/scripts/components/theme-toggle.js"></script>
{% capture css %}
{% render "../../../assets/styles/components/theme-toggle.css" %}
{% endcapture %}
<style>{{ css }}</style>
<template id="theme-toggle-template">
<button class="theme__toggle">
<span class="light">
{% tablericon "sun" "Toggle light theme" %}
</span>
<span class="dark">
{% tablericon "moon" "Toggle dark theme" %}
</span>
</button>
</template>
<li class="client-side">
<theme-toggle></theme-toggle>
</li>
{% endraw %}
I load the web component script, embed my styles, define the template such that preferred icons are included using Eleventy shortcodes and the result is wrapped in an li
to match the rest of my menu items, with .client-side
added to hide the component should JavaScript be disabled.
The complete JavaScript can be viewed in the source for my site.
{% render "partials/banners/npm.liquid", url: 'https://www.npmjs.com/package/@cdransf/theme-toggle', command: 'npm i @cdransf/theme-toggle' %}