Skip to content

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 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.

  1. 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.

    <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>
  2. 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.

    <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>
  3. 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.

    <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.

  1. 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>
  2. 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.

    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:

tailwind.config.cjs
/** @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.

src/env.d.ts
/// <reference types="astro/client" />
interface Window {
theme: {
setTheme: (theme: "auto" | "dark" | "light") => void;
getTheme: () => "auto" | "dark" | "light";
getSystemTheme: () => "light" | "dark";
getDefaultTheme: () => "auto" | "dark" | "light";
};
}

Usage

Layout.astro
---
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>