Add dark mode to Astro with Tailwind CSS

Kevin Zuniga Cuellar@kevinzunigacuel

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>
);
}
π§βπ§ Rendering components on the server
Regardless of the UI framework you use if you are using SSG, Astro will render your UI components on the server at build time and hydrate them on the client. This is a great feature because it makes your website faster, more accessible and SEO friendly.
However, this feature also comes with some tradeoffs. Because your components are rendered on the server, web APIs like localStorage
or window
are not available.
Fallback initial state
To get around this problem, we can add a fallback initial state that will be used at build time and then change to the correct state after hydration.
const [theme, setTheme] = useState(localStorage.getItem("theme") ?? "light");
The above code will try to get the theme from localStorage
and if itβs not available it will use light
as the initial state.
Using a fallback initial state is the most common way to solve this problem. However, it creates a new problem called βclient/server state missmatch.β This problem happens when the initial state is different from the state after hydration.
This is not a big problem but it can be annoying for users specially if the state change is noticeable.
Getting around client/server missmatch
One way to get around client and server missmatch is to add a mounted state. This state will make your component render wait until it is mounted to the DOM. This way all the web APIs will be available and the initial state will be the same as the state after hydration.
To implement this, we can 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>;