tohuwabohu In technology, chaos reigns supreme.

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

  1. The Problem
  2. CSS3 to the Rescue
  3. Building it from Scratch

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.

Tagged as: astro css front-end