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)