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 two parts, one that script that handles the theme and provides global methods available on the window, and one example web component that the client interacts with to manually select the theme. We will also go over styling, usage and tooling.
Respects user preferences and updates when user changes their preferences when Javascript is disabled
Allows for setting a default theme easily with a prop
Includes minimal <select> based web component (which isn’t necessary to use the script in ThemeManager.astro)
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
This .astro component 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 ensuring there is no FOUC, creating the window.theme client-side API, and 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 View Transitions.
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.
The first part of our script passes the defaultTheme prop to our window.theme IIFE, and then we create the store variable. We need to check if localStorage is available to us because it isn’t available everywhere and make sure we degrade the functionality gracefully when it isn’t.
Next, let’s listen for device setting changes so that when in auto mode, the theme will respond to clients changing their device settings. To do that, we also need to create the applyTheme function.
Now, let’s create the methods that will become our developer-facing API, which is designed for improved DX when working with this theme provider. Any function we return here will be available client-side on the global window.theme, like window.theme.getTheme(). Then, finally, we 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. 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 eslintrc.cjs to solve this.