Here are the features of a perfect theme toggle on the web according to me:
Doesn’t flicker when site loads
Defaults to system, adjusts if you change it
Remembers user’s last choice
Keeps last scheme when page loads from back/forward cache
Has ‘Light’, ‘Dark’, and ‘System’ options
Is convenient for developer to extend and work with
(bonus) works when JS bundle hasn’t arrived
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:
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:
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:
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.
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.
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.
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:
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:
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.
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:
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:
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:
Much better, don’t you think? Let’s do this. There are tradeoffs, though:
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.
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:
This code is verbose and imperative, but does exactly what we want. Note a few things:
We now need to use window.matchMedia to track system’s preferred theme
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:
HTML starts being parsed.
If JS is disabled, ThemeScript doesn’t work, website will default to system theme.
If JS is enabled, ThemeScript sets correct theme based on previous choice.
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:
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:
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:
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:
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:
And let’s make our theme selector also be aware what’s chosen:
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:
And let’s create a module that handles theme state.
You can now subscribe to theme changes in any Astro component.
Or, if you’re using React, first install dependency:
And in your component:
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!