Constraints
- Zero JavaScript - this ensures the theme switching works without JavaScript enabled
Since we are enforcing strictly zero JavaScript, we have no way of remembering the user’s choice, meaning TODO
Implementation
We are going to assume a CSS file with the following content:
@import "tailwindcss";Theme Switcher Component
First, let’s start of with the theme switcher component. It’s very simple, renders a hidden checkbox and based on whether it is checked, it hides/shows one of the icons.
---import { Icon } from "astro-icon/components";---
<div class="rounded-lg p-1 bg-red-500 size-fit aspect-square relative cursor-pointer"> <label for="theme" class="sr-only">Choose colour theme:</label> <input data-theme-selector id="theme" name="theme" type="checkbox" class="peer absolute opacity-0 inset-0 size-full" /> <Icon class="size-5 hidden peer-checked:block" name="lucide:moon" /> <Icon class="size-5 block peer-checked:hidden" name="lucide:sun" /></div>And that is it for the component - the trick here is to use Tailwind’s peer feature to detect when the checkbox is checked and hide icons accordingly.
As you can see, I’m using the lovely astro-icon library to pull Lucide icons.
Using Dark Mode
Now that we can toggle dark mode, it’s time to figure out how we use the toggle across the app. Ideally, we’d want to follow Tailwind’s dark variant as it integrates nicely with the rest of Tailwind.
However, the default dark variant relies on media queries which we absolutely do not want since we need to switch themes manually! Fortunately, we can override the variant to track the state of our checkbox inside the <ThemeSwitcher>:
@import "tailwindcss";@custom-variant dark (&:where(body:has([data-theme-selector]:checked), body:has([data-theme-selector]:checked) *));All this says is where the body has a theme selector that is checked. We want this variant to apply to all elements in the body so we target all children of <body> too.
And now we can apply dark mode handling to our elements as normal like:
---import "../styles/global.css";import ThemeSwitcher from "../components/theme-switcher.astro";---
<html> <body> <div class="absolute top-4 right-4"> <ThemeSwitcher /> </div> <p class="dark:bg-red-500"> </body></html>Initial State
Great! We can now switch themes without any JavaScript. However, if the user visits the page when their system preference is set to the dark mode, they will still see a light mode page and will have to manually switch themes. Since we have no JavaScript to read preferences from (e.g., localStorage or cookies), we will rely on a neat trick.
As an aside, we could get around this using client hints that inform the server about what preferences a client has. However, client hints are still experimental (at the time of writing) and, this theme switcher is for this very site - a static HTML site with no server.
The approach is as follows:
- Render two theme switches, one for light and one for dark mode
- Check (enable) both switches initially
- Hide one based on the user’s preference via the
prefers-color-schememedia query
We check both switches initially as they are intended to operate as:
- When the user prefers light mode, light mode should be enabled
- When the user prefers dark mode, dark mode should be enabled
So one theme switch enabled semantically means enable light mode and the other means enable dark mode.
Essentially, we want:
---import "../styles/global.css";import ThemeSwitcher from "../components/theme-switcher.astro";---
<html> <body> <div class="absolute top-4 right-4"> <ThemeSwitcher /> <ThemeSwitcher id="light" class="light hidden not-prefers-dark:block" /> <ThemeSwitcher id="dark" class="hidden prefers-dark:block" /> </div> <p class="dark:bg-red-500">foo</p> </body></html>You may have noticed a slight issue with this approach - our custom dark Tailwind variant checks if there’s any theme selector that is checked; this means if we render the two theme switches enabled, we will always be in dark mode! The solution is to modify the dark variant to work conditionally:
@import "tailwindcss";@custom-variant dark (&:where(body:has([data-theme-selector]:checked), body:has([data-theme-selector]:checked) *));@custom-variant prefers-dark { @media (prefers-color-scheme: dark) { @slot; }}
@custom-variant dark { @media (prefers-color-scheme: dark) { &:where(body:has(#dark:checked), body:has(#dark:checked) *) { @slot } }
@media not (prefers-color-scheme: dark) { &:where(body:has(#light:not(:checked)), body:has(#light:not(:checked)) *) { @slot } }}In other words, when the user prefers dark mode, dark mode is controlled by the dark mode selector and is enabled when the dark mode selector is checked; when the user prefers light mode, dark mode is controlled by the light mode selector and is disabled when the light mode selector is checked.
Of course, we need to update our theme switcher to accept these changes:
---import { Icon } from "astro-icon/components";
interface Props { id: string; class: string;}
const { id, class: className } = Astro.props;---
<div class="rounded-lg p-1 bg-red-500 size-fit aspect-square relative cursor-pointer"> <label for="theme" class="sr-only">Choose colour theme:</label><div class:list={["theme-selector rounded-lg p-1 z-1 bg-red-500 relative", className]}> <label for={id} class="sr-only">Choose colour theme:</label> <input data-theme-selector id="theme" name="theme" id={id} name={`theme-${id}`} type="checkbox" class="peer absolute opacity-0 inset-0 size-full" class="absolute opacity-0 inset-0 size-full cursor-pointer" checked /> <Icon class="size-5 hidden peer-checked:block" name="lucide:moon" /> <Icon class="size-5 block peer-checked:hidden" name="lucide:sun" /> <Icon class="size-5 hidden dark:block" name="lucide:moon" /> <Icon class="size-5 block dark:hidden" name="lucide:sun" /></div>State Management
If you try this out yourself you’ll find there’s one more issue: state isn’t tracked properly when you switch your computer’s theme.
TODO