Having a light and dark theme is almost necessary for a website, and there are many ways to handle this. This recipe will cover one method that follows best practices and keeps DX in mind.
This recipe will have three parts:
Client side scripts that handles the theme and provides global methods available on the window.
Respects user preferences and updates when user changes their preferences even when Javascript is disabled
Allows for setting a default theme easily with a prop
Exposes window.theme global for a nice API:
theme.getTheme()
theme.setTheme()
theme.getSystemTheme()
theme.getDefaultTheme()
Dispatches a custom theme-changed event that gives access to:
event.detail.theme
event.detail.systemTheme
event.detail.defaultTheme
Theme Manager Component
The core functionality of this recipe lives in this .astro component. It consists of two <script>s, the first is an inline <script> that accepts the defaultTheme prop and will live in the <head> of your layout or pages, it is responsible for:
Dispatching a custom event theme-changed whenever the theme changes.
The second script is not inline and adds an event listener for astro:after-swap to make this work with Astro’s View Transitions polyfill (Renamed to <ClientRouter/> in v5).
The first script is an IIFE and checks if window.theme already exists before executing. This prevents the global scope from being polluted and ensures we don’t see any Identifier has already been declared errors. The second script is specifically not inline so we don’t have to worry about the potential for redundant event listeners.
First we create our inline script. We pass the defaultTheme prop to this script, then create the store variable. We also need to check if localStorage is available to us, and make sure we fallback gracefully when it isn’t.
Next, let’s listen for device setting changes so that auto mode will work properly. To do that, we also need to create the applyTheme function.
Note that our applyTheme function will also set the color-scheme, and the way we have set this up relies on our theme names being light and dark, if you are going to extend this to have other themes you’ll need to refactor this.
Now, let’s create the methods that will become our developer-facing API. Any function we return here will be available client-side on the global window.theme. Finally, we need to set the initial theme.
Theme Select Component
Of course we need a way to allow users to switch between themes, and for this recipe we will go over a basic <select> based element. A more complex theme toggle button is included in the example repo.
Get started with another inline script that is defining a custom element
Next, set up the connectedCallback and methods of our component, with the goal of basically just creating a <select> component that sets the options correctly based on the current theme, listens for the theme-changed event and responds accordingly.
Styles
So, obviously, our theme solution wouldn’t be complete without styling the different themes! This can be done many ways, of course, but in essence, we will be setting up CSS variables according to the data-theme.
One important consideration is what happens when Javascript is disabled. There are two options here: chose a default theme or respect the users system theme. In the code below we have chosen to respect the users system theme. To ship a default theme remove the media query and set the variables for :root to the theme you want as a default.
Tailwind darkMode
What would a recipe’s style section be if it didn’t mention Tailwind CSS, especially when setting it up is easy as this:
ESLint and TypeScript
If you want to use this window.theme API inside a normal <script>, you might want to add it as a property of Window in env.d.ts.
If you’re using ESLint, there’s a good chance you’ll run into 'theme' is not defined due to the no-undef rule. We can add theme as a global in our ESlint config file to solve this.