A great way to make your website more accessible is to add dark mode. In this guide, we will learn how to implement perfect dark mode to your Astro project using TailwindCSS.
To create the UI we will use preact but feel free to use any other framework of your preference.
π§βπ» Getting started
Start a new Astro project:
npm create astro@latest
Install the tailwindcss and preact integrations:
npm install -D @astrojs/tailwind @astrojs/preact
npm install preact
Add both integrations to your astro.config.mjs
import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind";
import preact from "@astrojs/preact";
export default defineConfig({
integrations: [preact(), tailwind()],
});
Create a minimal tailwind config file in the root of your project.
-
Modify the
content
property to include all the files that contain your styles. -
Modify the
darkMode
property toclass
to enable dark mode.
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx,astro}"],
darkMode: "class",
theme: {},
plugins: [],
};
π©βπ Hands-on time!
Astro has a feature to add inline scripts directly to your astro files. These scripts will run as soon as the html is loaded; therefore, preventing the flash of inaccurate color theme which is a very common problem when implementing dark mode with hydration. You can read more about inline scripts in the Astro documentation.
The following code retrieves the userβs prefered theme and adds it to the html element. Feel free to copy/paste or modify this code snippet into your astro project. We will go over every line of code in the next paragraph.
<script is:inline>
const theme = (() => {
if (typeof localStorage !== "undefined" && localStorage.getItem("theme")) {
return localStorage.getItem("theme");
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
})();
if (theme === "light") {
document.documentElement.classList.remove("dark");
} else {
document.documentElement.classList.add("dark");
}
window.localStorage.setItem("theme", theme);
</script>
theme
is an immediately invoked function expression (IIFE) that returns the current theme based on the userβs preference.
Inside theme, the first if statement checks if the user has a previously saved theme in localStorage. If so, it returns set theme.
The second if statement checks if the user prefers dark mode. If so, it returns dark
.
Finally, if none of the above conditions are met, it returns light
.
Now that we have a theme defined, we can use it to add or remove dark
to the html element and save the theme to localStorage.
π Making the UI
In Astro you can use any UI framework of your choice. For this example, I decided to use Preact because of its small size and performance.
The following code snippet renders a button to toggle between dark and light mode.
import { useEffect, useState } from "preact/hooks";
import type { FunctionalComponent } from "preact";
export default function ThemeToggle(): FunctionalComponent {
const [theme, setTheme] = useState(localStorage.getItem("theme") ?? "light");
const handleClick = () => {
setTheme(theme === "light" ? "dark" : "light");
};
useEffect(() => {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
localStorage.setItem("theme", theme);
}, [theme]);
return (
<button onClick={handleClick}>{theme === "light" ? "π" : "π"}</button>
);
}
π§βπ§ Static Site Generation Problems
When using static site generation, the initial state will be undefined at build time because localStorage
is not available at the server. To solve this problem we can either:
- Add a fallback initial state, or
- Add a mounted state that will wait until the component is mounted and localStorage is available.
Fallback initial state
Adding a fallback initial state is the most common way to solve this problem. However, it creates a new problem called UI flick. Essentially, the UI will flick every time the server render doesnβt match your client render.
You may have seen it before with login and logout buttons. When an authenticated user revisits or refreshes the page, the page first renders in a not authenticated state but changes as soon as the page finish loading to authenticated.
To implement the fallback initial state, we can use the nullish coalescing operator ??
to set the initial state.
const [theme, setTheme] = useState(localStorage.getItem("theme") ?? "light");
Mounted state
Another way to solve the SSG problem is to add a mounted state. This state will make your button render wait until the component is mounted. This way the local storage theme will be available at the first render.
To implement we use useState
and useEffect
hooks to create a mounted state. This will render a fallback UI or null until the component is mounted.
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <FallbackUI />; // or null;
}
return <button>{theme === "light" ? "π" : "π"}</button>;