spilled.online

Build a Perfect Dark Theme Toggle with Astro


What is ‘perfect’?

Here are the features of a perfect theme toggle on the web according to me:

I think MDN’s website theme toggle qualifies as perfect, however, it fails on back/forward cache requirement (as of Jan’25). What we will build now is very similar.

Building the perfect theme toggle with Astro

I’m going to assume that you already have a website powered by Astro. Or you can just go with Astro starter on StackBlitz. I will also assume that you haven’t made too many theme toggles before, so I will shortly explain some basics along the way, which to experienced dark theme builder might seem obvious.

The CSS part

Let’s start with basic CSS theming. Because by default CSS can handle a theme set in your environment. It would look like this:

@media (prefers-color-scheme: dark) {
  html {
    --color-text: #fff;
    --color-background: #000;
  }
}
 
@media (prefers-color-scheme: light) {
  html {
    --color-text: #000;
    --color-background: #fff;
  }
}
 
body {
  background-color: var(--color-background);
  color: var(--color-text);
}

We’re using CSS variables, providing them different value based on theme. That way we won’t have to care about class names or put each rule into @media query separately. Elements that should be painted according to a theme will simply use a variable.

Because Astro bundles styles for you, and you want to make this style global, let’s create a component with global styles, for which we will use is:global directive:

src/components/ThemeStyles.astro
<style is:global>
/* put the CSS code from above here */
</style>

Then put that component into your layout, which probably lives in some src/layouts/layout.astro file, to which you will import component like this:

src/layouts/layout.astro
---
import ThemeStyles from '../components/ThemeStyles.astro';
const { frontmatter } = Astro.props;
---
<html lang="en">
  <head>
    <ThemeStyles />
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={Astro.generator} />
    <title>{frontmatter.pageTitle}</title>
  </head>
  <body>
    <slot />
  </body>
</html>

Now, you can leave this as is and your website will have a dark theme!

However, the only way to toggle it will be to change the theme of your device. Sometimes that’s enough. But it’s not what we want. We want to give users the power to choose.

So let’s stop for a second and think. How can we set a dark theme when system theme is light? We would need a way to override colors set inside @media queries with a theme that’s been set by user.

Let’s do that using CSS classes.

:root.dark {
  --color-text: #fff;
  --color-background: #000;
}
 
:root.light {
  --color-text: #000;
  --color-background: #fff;
}
 
/* System defaults */
@media (prefers-color-scheme: dark) {
  :root {
    --color-text: #fff;
    --color-background: #000;
  }
}
 
@media (prefers-color-scheme: light) {
  :root {
    --color-text: #000;
    --color-background: #fff;
  }
}
 
body {
  background-color: var(--color-background);
  color: var(--color-text);
}

Now when our root element has dark or light class, corresponding variables will override whichever is set by default because of higher specificity of the selector. Higher specificity is needed so that we fall back to system defaults only when the theme has not been explicitly overriden.

Let’s proceed.

Basic toggling

Toggling of classes will be handled by JavaScript. And there’s an important caveat that even big websites sometimes miss. We don’t want the script to be bundled and loaded asyncronously. We want it to block rendering. That means that by the time browser renders first paint, it already knows which theme was chosen. Otherwise colors may switch after the page has been rendered and JavaScript kicked in, creating a dreaded flicker.

First, let’s write a simple function that toggles classes.

function setColorMode(colorMode) {
  const root = window.document.documentElement;
  root.classList.toggle('light', colorMode === 'light');
  root.classList.toggle('dark', colorMode === 'dark');
}

This function will be called by an event listener that we will set for a custom event. Event allows us to uncouple business logic from the UI implementation, on which we will decide later.

function setColorMode(colorMode) {
  root.classList.toggle('light', colorMode === 'light');
  root.classList.toggle('dark', colorMode === 'dark');
}
document.addEventListener('theme-toggle-event', (e) => {
  setColorMode(e.detail);
});

OK now we put this script somewhere. In Astro we have an option to inline any script, which is exactly what we want here. Let’s wrap our script into an IIFE, place it into a component, and insert it into <head> of our layout:

src/components/ThemeScript.astro
<script is:inline>
  document.addEventListener('theme-toggle-event', (e) => {
    setColorMode(e.detail);
  });
 
  function setColorMode(colorMode) {
    const root = window.document.documentElement;
    root.classList.toggle('light', colorMode === 'light');
    root.classList.toggle('dark', colorMode === 'dark');
  }
</script>
src/layouts/layout.astro
---
import ThemeScript from '../components/ThemeScript.astro'; 
const { frontmatter } = Astro.props;
---
<html lang="en">
  <head>
    <ThemeScript />
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={Astro.generator} />
    <title>{frontmatter.pageTitle}</title>
  </head>
  <body>
    <slot />
  </body>
</html>

Alright, we’ve made sure that our script will block rendering until theme is correctly set.

You can now open your website and toggle theme by dispatching a theme-toggle-event event! Open the browser’s console and execute this code:

const event = new CustomEvent('theme-toggle-event', {
  detail: 'light', // or 'dark' if your current theme is light
});
 
document.dispatchEvent(event);

Use this approach to test your website until we create a UI for toggling.

Remembering the choice

Now we will keep user’s choice on their browser’s memory. It’s common to use localStorage here, let’s stick to it.

(function () {
  // Check if we have saved theme and immediately make it active
  const persistedColorPreference = localStorage.getItem('color-mode'); 
  if (persistedColorPreference) { 
    setColorMode(persistedColorPreference); 
  } 
 
  document.addEventListener('theme-toggle-event', (e) => {
    setColorMode(e.detail);
  });
 
  function setColorMode(colorMode) {
    const root = window.document.documentElement;
    root.classList.toggle('light', colorMode === 'light');
    root.classList.toggle('dark', colorMode === 'dark');
    localStorage.setItem('color-mode', colorMode); 
  }
})();

This code sort of works, but it doesn’t allow for a ‘system’ option. ‘System’ means “use whichever theme is set on device”

It also means that when user changes theme on device while viewing our website, the website should adjust accordingly without having to reload.

If you look back at our CSS code, we already have a native solution to this problem. We can just remove the light or dark class from root element, and CSS will take care of the rest.

So we could do it like like this and call it a day:

function setColorMode(colorMode) {
  const root = window.document.documentElement;
 
  if (colorMode === 'system') { 
    root.classList.remove('light', 'dark'); 
    localStorage.removeItem('color-mode'); // clean up memorized choice
  } else {
    root.classList.toggle('light', colorMode === 'light');
    root.classList.toggle('dark', colorMode === 'dark');
    localStorage.setItem('color-mode', colorMode);
  } 
}

But you could feel that there was a “but” coming. In my requirements I said that I want theming to be convenient for developer.

Let’s see.

Say, I want to make an element blue only in dark theme. (Yes, I’m bad at design. So are you.) I will have to do something like this:

:root.dark {
  --color-text: #fff;
  --color-background: #000;
  --ugly-element-color: none; 
}
 
:root.light {
  --color-text: #000;
  --color-background: #fff;
  --ugly-element-color: transparent; 
}
 
/* omitted for brevity */
 
body {
  background-color: var(--color-background);
  color: var(--color-text);
}
 
.ugly-element { 
  background-color: var(--ugly-element-color); 
} 

It works. But I could have many ugly elements, each with their own behavior that is not consistent with the main palette. I don’t like the idea of adding variables for all of them. I would rather have this option:

.dark .ugly-element {
  background-color: blue;
}

Much better, don’t you think? Let’s do this. There are tradeoffs, though:

  1. It will increase complexity of our script because we can’t fall back to CSS @media feature for ‘System’ anymore. We will have to always toggle classes to set a theme.
  2. ugly-element will not get blue if JS is disabled for whatever reason. Our solution becomes slightly less “native” because stop relying on CSS for theming and do it by hand instead.

Here is the (almost) final look:

src/components/ThemeScript.astro
<script is:inline>
(function(){
  const root = window.document.documentElement;
  const persistedColorPreference = localStorage.getItem('color-mode');
  const mQuery = window.matchMedia('(prefers-color-scheme: dark)');
 
  if (persistedColorPreference) {
    setColorMode(persistedColorPreference);
  } else {
    setColorMode();
  }
 
  // Listen to user setting theme
  document.addEventListener("theme-toggle-event", (e) => {
    setColorMode(e.detail);
  });
 
  // Listen to device theme change
  mQuery.addEventListener('change', () => {
    const isSystem = root.classList.contains('system');
    if (isSystem) {
      setColorMode();
    }
  });
 
  function setColorMode(colorMode = 'system') {
    if (colorMode === 'system') {
      root.classList.add('system');
      const isDefaultDark = mQuery.matches;
      root.classList.toggle('light', !isDefaultDark);
      root.classList.toggle('dark', isDefaultDark);
      localStorage.removeItem('color-mode');
    } else {
      root.classList.remove('system');
      root.classList.toggle('light', colorMode === 'light');
      root.classList.toggle('dark', colorMode === 'dark');
      localStorage.setItem('color-mode', colorMode);
    }
  }
})();
</script>

This code is verbose and imperative, but does exactly what we want. Note a few things:

  1. We now need to use window.matchMedia to track system’s preferred theme
  2. We also use ‘system’ class to indicate that whichever theme is chosen, it’s based on the system — will need it later

We’re done with the ThemeScript.astro!

Now let’s create a UI component that will allow the toggling to happen by dispatching the event we’re listening to.

Create toggle component

To offer 3 options, ‘Dark’, ‘Light’, and ‘System’, let’s go the easy way with select element to display a native dropdown.

We can create an Astro component that renders on the server and is immediately shown on the page. I suggest that we don’t because of the last point in our perfect-toggle requirements list, which is an ability to (somewhat) work without JavaScript. In this case our website will not (and should not) offer any toggling.

It will happen like this:

  1. HTML starts being parsed.
  2. If JS is disabled, ThemeScript doesn’t work, website will default to system theme.
  3. If JS is enabled, ThemeScript sets correct theme based on previous choice.
  4. When JS bundle arrives and executes, we display a toggle that we can be sure is working.

Sounds like a good progressive enhancement to me!

That means that instead of component we will create a script that puts dropdown to the website’s header:

src/ThemeToggle.astro
<script>
  const select = document.createElement('select');
  const header = document.querySelector('header');
  const root = window.document.documentElement;
 
  select.innerHTML = `
    <option value="light">Light</option>
    <option value="dark">Dark</option>
    <option value="system">System</option>
  `;
 
  select.addEventListener('change', (event) => {
    const theme = (event.target as HTMLSelectElement).value;
    document.dispatchEvent(
      new CustomEvent('theme-toggle-event', { detail: theme }),
    );
  });
 
  header?.appendChild(select);
</script>

And let’s add this script to our layout, because we want it to work on every page. Thanks to Astro’s syntax, it’s very easy to do:

src/layouts/Layout.astro
---
import ThemeScript from '../components/ThemeScript.astro';
import ThemeStyles from '../components/ThemeStyles.astro';
import ThemeToggle from '../components/ThemeToggle.astro';
const { frontmatter } = Astro.props;
---
<html lang="en">
  <head>
    <ThemeStyles />
    <ThemeScript />
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={Astro.generator} />
    <title>{frontmatter.pageTitle}</title>
  </head>
  <body>
    <header>
      <h1>My website titile</h1>
      <ThemeToggle />
    </header>
    <slot />
  </body>
</html>

WAIT!

Did you notice a big problem with our toggle?

If you haven’t, have a look once again or try running the code we’ve got so far.

The problem is that our select element has no idea which theme is selected when it loads! Let’s fix it by looking if a class specific has been set to root element.

So when dropdown script loads, let it know which theme is chosen:

src/components/ThemeToggle.astro
<script>
  const select = document.createElement('select');
  const header = document.querySelector('header');
  const root = window.document.documentElement;
  const isSystem = root.classList.contains('system');
  const isLight = root.classList.contains('light') && !isSystem;
  const isDark = root.classList.contains('dark') && !isSystem;
 
  select.innerHTML = `
    <option value="light" ${isLight ? 'selected' : ''}>Light</option>
    <option value="dark" ${isDark ? 'selected' : ''}>Dark</option>
    <option value="system" ${isSystem ? 'selected' : ''}>System</option>
  `;
 
  select.addEventListener('change', (event) => {
    const theme = (event.target as HTMLSelectElement).value;
    document.dispatchEvent(
      new CustomEvent('theme-toggle-event', { detail: theme }),
    );
  });
 
  header?.appendChild(select);
</script>

That’s it!

A caveat. Back/forward cache

By default, with Astro your website will be a multi-page web application. Browsers try to make them snappy when it’s possible. For example, when you click “back” it might instantly load the previous page from a snapshot that’s kept in back/forward cache.

For us it means that, if you go to some page, change theme there and hit ‘back’, you will see the previous page instantly and exactly the way it was — with old theme. Here’s how it looks on MDN as of January 2025:

When going back in Safari on MDN website, previous page loads with old theme

It’s not to dunk on MDN developers, it’s a rare case and they have more important things to do.

But as for us, we’re pursuing perfection here. Nothing is more important.

Thankfully, a solution exists and is not complicated, albeit sort of ugly. Browsers notify us of page being loaded from cache via persisted flag on pageshow event.

What’s not pretty about this is that we have separated concerns for theme and toggle. You may choose a

So, let’s adjust the theme script:

src/components/ThemeScript.astro
<script is:inline>
(function(){
 
  // ... code above
 
  // Listen to device theme change
  mQuery.addEventListener('change', () => {
    const isSystem = root.classList.contains('system');
    if (isSystem) {
      setColorMode();
    }
  });
 
  // listen to load from back/forward cache
  window.addEventListener('pageshow', (e) => { 
    if (e.persisted) { 
      const persistedColorPreference = localStorage.getItem(colorModeKey); 
      setColorMode(persistedColorPreference || 'system'); 
    } 
  }); 
 
  function setColorMode(colorMode = 'system') {
  
  // ... code below
})();
</script>

And let’s make our theme selector also be aware what’s chosen:

src/components/ThemeToggle.astro
<script>
  const select = document.createElement('select');
  const header = document.querySelector('header');
  const root = window.document.documentElement;
  const isSystem = root.classList.contains('system');
  const isLight = root.classList.contains('light') && !isSystem;
  const isDark = root.classList.contains('dark') && !isSystem;
 
  select.innerHTML = `
    <option value="light" ${isLight ? 'selected' : ''}>Light</option>
    <option value="dark" ${isDark ? 'selected' : ''}>Dark</option>
    <option value="system" ${isSystem ? 'selected' : ''}>System</option>
  `;
 
  select.addEventListener('change', (event) => {
    const theme = (event.target as HTMLSelectElement).value;
    document.dispatchEvent(
      new CustomEvent('theme-toggle-event', { detail: theme }),
    );
  });
 
  // listen to load from back/forward cache
  window.addEventListener('pageshow', (e) => { 
    if (e.persisted) { 
      setTimeout(() => { 
        const isSystem = root.classList.contains('system'); 
        const isLight = root.classList.contains('light') && !isSystem; 
        const isDark = root.classList.contains('dark') && !isSystem; 
        select.value = isSystem ? 'system' : isLight ? 'light' : 'dark'
      }, 0) 
    } 
  }); 
 
  header?.appendChild(select);
</script>

And we’re done.

A caveat #2. What’s with different parts of application?

This approach works perfectly well if you need to change colors and some arbitrary styles of page elements with theme. Usually that’s enough.

However, sometimes you need more. For example, what if you’re using WebGL? Then colors must be changed with JS directly. Or your React Island should render different thing for different theme?

Our ThemeScript does some heavy lifting already, but it’s not enough.

No problem! What we’re dealing here with is a state management problem. So let’s treat it as one. Astro’s docs have a great suggestion with recipes - sharing state between components and islands.

Solution

We will use nanostores library as Astro’s docs suggest. Problem is, there’s no straightforward way to add it to ThemeScript.astro because it’s inlined and not bundled. We can’t use it there.

But then again, we shouldn’t. We would only need this library when other parts of the app hydrate. So we’ll put it into a separate file and let Astro do the rest.

First, install dependency:

npm install nanostores

And let’s create a module that handles theme state.

src/themeStore.ts
import { atom } from 'nanostores';
 
const theme = atom<'light' | 'dark'>(getCurrentTheme());
 
function getCurrentTheme(): 'light' | 'dark' {
  const root = document.documentElement;
  if (root.classList.contains('dark')) return 'dark';
  if (root.classList.contains('light')) return 'light';
}
 
function updateTheme() {
  theme.set(getCurrentTheme());
}
 
// Observer for class changes
const rootObserver = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
      updateTheme();
    }
  });
});
rootObserver.observe(document.documentElement, { attributes: true });
 
export { theme };

You can now subscribe to theme changes in any Astro component.

src/components/Message.astro
<span class="message"></span>
<script>
  import { theme } from '../themeStore.ts';
 
  theme.subscribe(theme => {
    const message = document.querySelector('span.message');
    message.innerText = `You have chosen ${theme === 'dark' ? 'Dark': 'Light'} theme`;
  })
</script>

Or, if you’re using React, first install dependency:

npm install @nanostores/react

And in your component:

src/components/Message.tsx
import { useStore } from '@nanostores/react';
import { theme } from '../../themeStore.ts';
 
export const Message = () => (
  <span className="message">
    You have chosen {currentTheme === 'dark' ? 'Dark' : 'Light'} theme
  </span>
);

Complete code

Here’s a complete code with minor changes on Stackblitz . The changes I have made are for styling and some fun.

Good improvement you can make is use SASS module where all colors are defined and exported, so that you can import it into ThemeStyles.astro as a basic SASS @use feature. You can also import these colors into any JS part of your website (again, WebGL would be a good example) because Astro uses Vite which allows importing values exported from SASS modules into JS. Try it out!

P.S. Do we even need dark themes?

We don’t need them, but they are a good convenience. Personally, I hate it when an app or a website does not have a way to override system theme. My devices are always on dark mode, but there are lots of things I like in light palette. So it’s a good option to have.

Let me know how did it go

The bar was set high because I promised a perfect theme toggle. I do think it is perfect in terms of how it works for user, but you may have other opinion both in terms of usability and in technical implementation. If you see that something can be done better, I would be curious to know — tell me!