How to handle dark mode
Astro ^4.0.0
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.
Check out an example of this recipe on StackBlitz or GitHub
Key Features
- View Transitions compatible
- Uses color-scheme CSS property to match user-agent stylesheet with theme
- 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 inThemeManager.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 ourwindow.theme
IIFE, and then we create thestore
variable. We need to check iflocalStorage
is available to us because it isn’t available everywhere and make sure we degrade the functionality gracefully when it isn’t.<script is:inline data-default-theme={defaultTheme}>window.theme ??= (() => {const defaultTheme =document.currentScript.getAttribute("data-default-theme");const storageKey = "theme";const store =typeof localStorage !== "undefined"? localStorage: { getItem: () => null, setItem: () => {} };// ...})();theme.setTheme(theme.getTheme());</script><script>document.addEventListener("astro:after-swap", () =>window.theme.setTheme(window.theme.getTheme()),);</script> -
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 theapplyTheme
function.<script is:inline data-default-theme={defaultTheme}>window.theme ??= (() => {// ...const mediaMatcher = window.matchMedia("(prefers-color-scheme: light)");let systemTheme = mediaMatcher.matches ? "light" : "dark";mediaMatcher.addEventListener("change", (event) => {systemTheme = event.matches ? "light" : "dark";applyTheme(theme.getTheme());});function applyTheme(theme) {const resolvedTheme = theme === "auto" ? systemTheme : theme;document.documentElement.dataset.theme = resolvedTheme;document.documentElement.style.colorScheme = resolvedTheme;document.dispatchEvent(new CustomEvent("theme-changed", {detail: { theme, systemTheme, defaultTheme },}));}// ...})();theme.setTheme(theme.getTheme());</script><script>document.addEventListener("astro:after-swap", () =>window.theme.setTheme(window.theme.getTheme()),);</script> -
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
, likewindow.theme.getTheme()
. Then, finally, we set the initial theme.<script is:inline data-default-theme={defaultTheme}>window.theme ??= (() => {// ...function setTheme(theme = defaultTheme) {store.setItem(storageKey, theme);applyTheme(theme);}function getTheme() {return store.getItem(storageKey) || defaultTheme;}function getSystemTheme() {return systemTheme;}function getDefaultTheme() {return defaultTheme;}return { setTheme, getTheme, getSystemTheme, getDefaultTheme };})();theme.setTheme(theme.getTheme());</script><script>document.addEventListener("astro:after-swap", () =>window.theme.setTheme(window.theme.getTheme()),);</script>
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
<theme-selector></theme-selector><script is:inline>if (!customElements.get("theme-selector")) {customElements.define("theme-selector",class extends HTMLElement {// ...});}</script> -
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 currenttheme
, listens for thetheme-changed
event and responds accordingly.class extends HTMLElement {connectedCallback() {this.innerHTML = `<select><option value="auto">Auto</option><option value="light">Light</option><option value="dark">Dark</option></select>`;this.querySelector("select").onchange = (event) =>theme.setTheme(event.target.value);this.setAttribute("aria-label", "Select Theme");this.updateSelectedTheme();document.addEventListener("theme-changed", (event) => {this.updateSelectedTheme(event.detail.theme);});}updateSelectedTheme(newTheme = theme.getTheme()) {this.querySelector("select").value = newTheme;}}
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.
<style is:global> :root, :root[data-theme="light"] { --background-color: #ffffff; --text-color: #000000; color-scheme: light; }
@media (prefers-color-scheme: dark), :root[data-theme="dark"] { :root { --background-color: #333333; --text-color: #ffffff; color-scheme: dark; } }
body { background-color: var(--background-color); color: var(--text-color); }</style>
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:
/** @type {import('tailwindcss').Config} */module.exports = { darkMode: ['selector', '[data-theme="dark"]'], // ...}
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.
/// <reference types="astro/client" />
interface Window { theme: { setTheme: (theme: "auto" | "dark" | "light") => void; getTheme: () => "auto" | "dark" | "light"; getSystemTheme: () => "light" | "dark"; getDefaultTheme: () => "auto" | "dark" | "light"; };}
/** @type {import("@types/eslint").Linter.Config} */module.exports = { globals: { theme: "readonly", }, // ...};
Usage
---import ThemeManager from "./ThemeManager.astro";import ThemeSelect from "./ThemeSelect.astro";import { ViewTransitions } from "astro:transitions";---
<html lang="en"> <head> <!-- ... --> <ThemeManager defaultTheme="auto" /> <ViewTransitions /> </head> <main> <body> <ThemeSelect /> <slot /> </body> </main> <style is:global> :root[data-theme="light"] { /* CSS variables */ } :root[data-theme="dark"] { /* CSS variables */ } </style></html>
Full code
---type Props = { defaultTheme?: "auto" | "dark" | "light" | undefined;};
const { defaultTheme = "auto" } = Astro.props;---
<script is:inline data-default-theme={defaultTheme}> window.theme ??= (() => { const defaultTheme = document.currentScript.getAttribute("data-default-theme"); const storageKey = "theme"; const store = typeof localStorage !== "undefined" ? localStorage : { getItem: () => null, setItem: () => {} };
const mediaMatcher = window.matchMedia("(prefers-color-scheme: light)"); let systemTheme = mediaMatcher.matches ? "light" : "dark"; mediaMatcher.addEventListener("change", (event) => { systemTheme = event.matches ? "light" : "dark"; applyTheme(theme.getTheme()); });
function applyTheme(theme) { const resolvedTheme = theme === "auto" ? systemTheme : theme; document.documentElement.dataset.theme = resolvedTheme; document.documentElement.style.colorScheme = resolvedTheme; document.dispatchEvent( new CustomEvent("theme-changed", { detail: { theme, systemTheme, defaultTheme }, }) ); }
function setTheme(theme = defaultTheme) { store.setItem(storageKey, theme); applyTheme(theme); }
function getTheme() { return store.getItem(storageKey) || defaultTheme; }
function getSystemTheme() { return systemTheme; }
function getDefaultTheme() { return defaultTheme; }
return { setTheme, getTheme, getSystemTheme, getDefaultTheme }; })(); theme.setTheme(theme.getTheme());</script><script> document.addEventListener("astro:after-swap", () => window.theme.setTheme(window.theme.getTheme()), );</script>
<theme-selector></theme-selector><script is:inline> if (!customElements.get("theme-selector")) { customElements.define( "theme-selector", class extends HTMLElement { connectedCallback() { this.innerHTML = ` <select> <option value="auto">Auto</option> <option value="light">Light</option> <option value="dark">Dark</option> </select> `; this.querySelector("select").onchange = (event) => theme.setTheme(event.target.value); this.setAttribute("aria-label", "Select Theme"); this.updateSelectedTheme();
document.addEventListener("theme-changed", (event) => { this.updateSelectedTheme(event.detail.theme); }); }
updateSelectedTheme(newTheme = theme.getTheme()) { this.querySelector("select").value = newTheme; } } ); }</script>