DEV Community

AjeaS
AjeaS

Posted on

Theme Switcher in Astro project

In short, I created a simple theme switcher using Tailwind CSS and some JS. Here's how I did it.

I've broken it down into 3 sections
global.css
Layout.astro
ThemeToggle.astro

global.css

I defined light theme color variables in the :root by default. Then, for dark mode, I re-defined those same variables for the dark theme. The dark theme only applies if there is a data-theme="dark"attribute set on the HTML tag.

:root {
    --font-body: DM Sans, sans-serif;
    --font-fira: Fira Code, monospace;

    --text-2xl: 2.5rem;
    --text-xl: 2rem;
    --text-lg: 1.75rem;
    --text-md: 1.5rem;
    --text-sm: 1.25rem;
    --text-xs: 1.125rem;
    --text-base: 1rem;

    --color-neutral-900: #1c1a19;
    --color-neutral-800: #201e1d;
    --color-neutral-700: #34302d;
    --color-neutral-600: #4a4846;
    --color-neutral-400: #c0bfbf;
    --color-neutral-300: #dedcda;
    --color-neutral-200: #efedeb;
    --color-neutral-100: #fbf9f7;
    --color-blue-900: #022b4a;
    --color-blue-800: #5792c0;
    --color-blue-700: #75b0de;
    --color-blue-500: #93cefc;
    --color-blue-200: #e1f1fe;
    --color-green-900: #132a18;
    --color-green-700: #008531;
    --color-green-500: #9dd3a9;
    --color-green-200: #e9f5ea;
    --color-yellow-900: #4a3003;
    --color-yellow-700: #ea9806;
    --color-yellow-500: #facc79;
    --color-yellow-200: #fff5e1;
    --color-red-600: #d92d20;
    --color-red-400: #f04438;
    --color-white: #ffffff;

    /* light mode */
    --border: var(--color-neutral-200);
    --background-color: var(--color-neutral-100);
    --text-color: var(--color-neutral-600);

    --heading-text: var(--color-neutral-700);

    --header-background: var(--color-white);
    --header-text: var(--color-neutral-800);

    --menu-toggle-background: var(--color-neutral-800);
    --menu-toggle-text: var(--color-white);

    --nav-background: var(--color-white);
    --nav-text: var(--color-neutral-600);

    --icon-fill: var(--color-neutral-900);
}

/* dark mode */
html[data-theme="dark"] {
    --border: var(--color-neutral-700);
    --background-color: var(--color-neutral-900);
    --text-color: var(--color-neutral-400);

    --heading-text: var(--color-white);

    --header-background: var(--color-neutral-800);
    --header-text: var(--color-white);

    --menu-toggle-background: var(--color-white);
    --menu-toggle-text: var(--color-neutral-800);

    --nav-background: var(--color-neutral-800);
    --nav-text: var(--color-neutral-400);

    --icon-fill: var(--color-white);
}
Enter fullscreen mode Exit fullscreen mode

NOTE: within my components, I use these color variables so they will switch accordingly when toggling the theme.
ex:

<div className="flex items-center justify-between gap-4 
bg-(--header-background) text-(--header-text) 
transition-colors duration-500 ease-in relative 
mt-5 mb-3 rounded-[10px] p-[6px] border border-(--border)">
Enter fullscreen mode Exit fullscreen mode

Layout.astro

I have a script tag in the head with a is:inline attribute so that this code runs before the HTML is rendered. In a nutshell,

  • I'm getting the theme from localstorage and if it doesn't exist it's set to "light" by default.
  • I'm setting the data-theme attribute to that value.
<html lang="en">
    <head>
        <ClientRouter />
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width" />
        <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
        <meta name="generator" content={Astro.generator} />
        <title>Personal Blog</title>

        <script is:inline>
            function applyTheme(doc) {
                // 1. Get the stored theme preference from localStorage
                const storedTheme = localStorage.getItem("theme") || "light";

                doc.documentElement.setAttribute("data-theme", storedTheme);
            }

            // Apply theme on initial page load
            applyTheme(document);

        </script>
    </head>
</html>

Enter fullscreen mode Exit fullscreen mode

NOTE: I'm using client-side routing with ClientRouter. However, when navigating to a new page (e.g. about) the old DOM is removed (along with its event listeners/data attributes) and the new DOM is created but without event listeners/data attributes attached.

So the data-theme attribute is not attached to the new DOM (about page).

Example: on the Home page, the theme is set to dark mode, if I go to the About page(new DOM everything removed) theme will be set back to default light mode.

FIX: to fix this, I needed to ensure that the data-theme attribute is set on the new DOM upon navigating.

So I used the astro:before-swap event listener to ensure the correct theme is applied to each page upon navigation.

// Apply theme to the new document during Astro View Transitions
            document.addEventListener("astro:before-swap", (event) => {
                applyTheme(event.newDocument);
            });
Enter fullscreen mode Exit fullscreen mode

ThemeToggle.astro

This component

  • Sets the data-theme attribute based on localstorage value
  • Displays the correct icons based on theme
  • Saves the theme to localstorage
<script>
    // Toggle theme icons and set theme attribute
    function applyTheme(theme: string) {
        const root = document.documentElement;
        const moonIcon = document.getElementById("moonIcon");
        const sunIcon = document.getElementById("sunIcon");

        // Set theme attribute and persist in localStorage
        root.setAttribute("data-theme", theme);
        localStorage.setItem("theme", theme);

        if (!moonIcon || !sunIcon) return;

        if (theme === "dark") {
            sunIcon.classList.remove("hidden");
            moonIcon.classList.add("hidden");
        } else {
            sunIcon.classList.add("hidden");
            moonIcon.classList.remove("hidden");
        }
    }

    function setUpThemeToggle() {
        const toggleButton = document.getElementById("themeToggle");
        if (!toggleButton) return;

        // Initialize from saved theme or fallback to light
        let currentTheme = localStorage.getItem("theme") || "light";
        applyTheme(currentTheme);

        toggleButton.addEventListener("click", () => {
            currentTheme = currentTheme === "light" ? "dark" : "light";
            applyTheme(currentTheme);
        });
    }

    // runs on page load
    setUpThemeToggle();

    // Reapply theme after Astro page transitions
    document.addEventListener("astro:after-swap", () => {
        setUpThemeToggle();
    });
</script>
Enter fullscreen mode Exit fullscreen mode

astro:after-swap fires after page navigations. There I re-fetched the theme from localstorage to ensure the correct icon is displayed. Hope that makes sense.

Lastly, for full context here's the HTML for my theme button

<button
    id="themeToggle"
    class="rounded-lg border border-(--border) bg-(--background-color) cursor-pointer transition-colors duration-500 ease-in p-2"
>
    <MoonIcon id="moonIcon" className="w-6 h-6 hidden" />
    <Sun id="sunIcon" class="w-6 h-6 hidden" />
</button>
Enter fullscreen mode Exit fullscreen mode

This is what's currently working for me at the moment. I'm open to suggestions on how to make it better/improve. 👌🙏🏽

Happy Coding!

Top comments (0)