Add a perfect dark mode in Astro with Tailwind CSS

Image of the author

Kevin Zuniga Cuellar @kevinzunigacuel

The logo of Astro and Tailwind CSS

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

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.

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

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

ThemeToggle.tsx
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:

  1. Add a fallback initial state, or
  2. 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>;