Dark Theme Toggle in Astro
A few weeks ago I evaluated a few static site generators for this site, and I’m really happy with what Astro has to offer. I try to aim for a rather minimalistic page design, but want to provide some convenience for visitors. I thought about what I liked on pages I visited and wished more pages on the web have. One of those things is a dark theme that automatically adapts to your preferred setting on the OS, but can be toggled as you wish. Also, I like to use pure HTML, CSS and JavaScript rather than major frameworks.
One of Astro’s features is that unnecessary JavaScript will be omitted during build to improve loading times. When it comes to interactive elements, you need to use some directives to exclude them from the removal process.
So, I had to wrap my head around a problem I totally did not have on my radar.
Table of Contents
The Problem
Client side JavaScript manipulation can only be performed after the page load happened. I implemented a slider with some
fancy transform animations that is controlled by a checkbox. The checkbox is unchecked by default, on my site that means
the light theme is selected. As my site does not use any cookies, the selection is stored in the browser’s
localStorage
.
The markup I use looks like this.
<div class="toggle-container">
<i class="fa fa-sun light"></i>
<div>
<label class="switch" aria-label="Switch theme">
<input id={id} class="theme-toggle" type="checkbox"
onclick="togglePreference()"/>
<span class="slider round"></span>
<span class="slider-label">Toggle theme</span>
</label>
</div>
<i class="fa fa-moon dark"></i>
</div>
And somewhere the localStorage
gets evaluated.
const getColorPreference = () => {
let preference = localStorage.getItem(storageKey);
if (!preference) {
preference = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return preference;
}
Credit’s due, there is some CSS I used from the W3C toggle template.
Because all pages you can visit on this website are pre-generated, all of them have an unchecked checkbox and
some logic that pulls the setting from the localStorage
. Because of the animation, visitors who have the dark theme
configured would see a ‘jumping’ toggle. Look at the video below.
As a quick fix I swallowed my pride and ditched the animation. But you could still see the switch from light to dark in split seconds. Technically, the only ‘right’ way to fix this would be generating a page for each checkbox state, resulting in a ‘dark’ and ‘light’ version of the page with separate slugs and that’s a big no for me.
I can’t possibly be the only one dealing with this problem, so how did others solve it? They also cheated. Even the
official Astro documentation
uses
the localStorage
JavaScript approach
and no fancy animation on their toggle. My keen eye spotted that their page has the same problem as well as other Astro
generated sites I found. Some of them even had fancy sliders implemented and seemingly gave up fixing them.
CSS3 to the Rescue
I used data-*
attributes to define theme colors, but it did not occur to me that you can use this for other values
as well, e.g. translateX
.
All I had to do was to define an initial value for the slider’s position, like this…
:root[data-theme="light"] {
/* ... */
--theme-slider-initial-transform: 0px;
}
:root[data-theme="dark"] {
/* ... */
--theme-slider-initial-transform: translateX(24px);
}
… and apply it to the :before
pseudoclass.
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
transform: var(--theme-slider-initial-transform);
transition: .4s;
}
Any other CSS class that applies translateX
on the checkbox checked
pseudoclass should be removed. This way, the
slider reacts to the data-theme
property instead of the checked
value. The checkbox now only serves as
element that makes sure togglePreference()
gets called.
Building it from Scratch
Some snippets thrown here and there are confusing. Create a new Astro project with npm create astro@latest
. I chose
the ‘personal website’ template.
This will create a template based on the lovely bear blog with enough pages to test everything and simple enough to expand. Open the project in an IDE of your choice.
There is no theme defined yet, so scrape all those color-related values you find in src/styles/global.css
and put them
into a root[data-theme="light"]
definition. Create a second one called root[data-theme="dark"]
and adapt those
colors to anything you consider pretty. Mine looks like this.
:root[data-theme="light"] {
--background-color: #fff;
--color: #444;
--heading-color: #222;
--link-color: #3273dc;
--link-visited-color: #193c75;
--code-background-color: #c7c7c7;
--theme-slider-initial-transform: 0px;
--light-theme-indicator-color: #ffcf00;
--dark-theme-indicator-color: var(--color);
}
:root[data-theme="dark"] {
--background-color: #444;
--color: #fff;
--heading-color: #f1f1f1;
--link-color: #de9906;
--link-visited-color: #966807;
--code-background-color: #252525;
--theme-slider-initial-transform: translateX(24px);
--light-theme-indicator-color: var(--color);
--dark-theme-indicator-color: #ffcf00;
}
Afterwards, create a new component called ThemeSlider
in a file src/components/ThemeSlider.astro
.
---
const { id } = Astro.props;
---
<div class="toggle-container">
<span class="light">
light
</span>
<div>
<label class="switch" aria-label="Switch theme">
<input id={id} class="theme-toggle" type="checkbox"
onclick="togglePreference()" />
<span class="slider round"></span>
</label>
</div>
<span class="dark">
dark
</span>
</div>
The id
is necessary to identify the <input>
control element to pair with a <label>
. When using ThemeSlider
, you
need to set the id
property like this: <ThemeSlider id="theme-toggle">
.
I opted for the W3C toggle example with round edges and some
minuscule changes in size. The CSS I use looks like this and can be appended in the ThemeSlider.astro
file with
matching <style>
tags.
.theme-toggle {}
.toggle-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5em;
}
.light {
color: var(--light-theme-indicator-color);
}
.dark {
color: var(--dark-theme-indicator-color);
}
.switch {
position: relative;
display: inline-block;
width: 48px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--link-color);
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: var(--color);
transform: var(--theme-slider-initial-transform);
transition: .4s;
}
input:checked + .slider {
background-color: var(--link-color);
}
input:focus + .slider {
box-shadow: 0 0 5px var(--link-color);
}
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
Add a ThemeSlider
component into the src/components/Header.astro
.
<div class="header-container">
<nav>
<HeaderLink href="/">Home</HeaderLink>
<HeaderLink href="/blog">Blog</HeaderLink>
<HeaderLink href="/about">About</HeaderLink>
<HeaderLink href="https://twitter.com/astrodotbuild"
target="_blank">Twitter</HeaderLink>
<HeaderLink href="https://github.com/withastro/astro"
target="_blank">GitHub</HeaderLink>
</nav>
<ThemeSlider id="theme-toggle" />
</div>
header-container
is just a simple flexbox that aligns the <nav>
on the left, and our new toggle on the right side.
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
}
The last jigsaw is calling togglePreference()
and setting up the localStorage
evaluation. Create a new file in
public/lib/
called theme-util.js
and look at the JavaScript code below.
const storageKey = 'theme-preference'
const getColorPreference = () => {
let preference = localStorage.getItem(storageKey);
if (!preference) {
preference = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return preference;
}
const setPreference = (themeName) => {
localStorage.setItem(storageKey, themeName)
document.firstElementChild
.setAttribute('data-theme', themeName);
}
const togglePreference = () => {
setPreference(getColorPreference() === 'dark' ? 'light' : 'dark');
}
(() => {
const theme = getColorPreference();
setPreference(theme);
})();
This code will ensure that the storageKey
value will be evaluated and the data-theme
property will be set
appropriately. Append the util as resource in src/components/BaseHead.astro
.
<!-- Theme Util -->
<script src="/lib/theme-util.js" type="text/javascript" is:inline></script>
That’s it! It was less work than expected and now there is no jumping visible. You find the full example on my GitHub page.