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); }
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)">
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
fromlocalstorage
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>
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); });
ThemeToggle.astro
This component
- Sets the
data-theme
attribute based onlocalstorage
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>
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>
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)