A Three-Way Theme Toggle: System, Light, Dark — Not Just prefers-color-scheme
This site has a theme button in the nav that, on click, cycles through three states: system → light → dark → back to system. I originally assumed following prefers-color-scheme alone without any manual control would be enough, but there turned out to be a good reason to add an explicit toggle.
Why isn’t prefers-color-scheme alone enough? Because some visitors have their browser/OS set to light by default (a work laptop with a forced-light policy, for example), but personally prefer reading long text in dark mode, or the reverse. A static site has no way to know that personal preference if it only relies on the OS signal.
The implementation uses a Tailwind custom variant. Instead of Tailwind’s built-in dark: variant, which by default follows the media query directly, I override it via @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); in global.css. So every dark: class across components now follows the data-theme attribute on the <html> element, not the raw media query.
The logic lives in a client-side script (ThemeToggle.astro): there are three modes (system, light, dark). When the mode is system, the resolved theme follows window.matchMedia('(prefers-color-scheme: dark)') — so it still follows the OS by default. Once the user clicks the toggle, the mode is saved to localStorage and the data-theme attribute is set directly, overriding the OS signal.
A small detail I kept strict: every localStorage and matchMedia access is wrapped in try/catch. In some environments (certain private browsing modes, strict browser extensions), these APIs can throw when accessed directly. A static site should still render correctly even when those APIs aren’t available, so the fallback quietly falls back to the default instead of crashing the page.
A listener for runtime OS changes is also in place — if the mode is still system and the user switches their OS preference mid-session (not a page reload), the site’s theme updates automatically without a refresh.
I originally assumed the simplest solution was having no toggle at all. It turns out the better version isn’t “simple in an absolute sense,” but “give the user control without forcing them to configure anything” — the default still follows the OS, but an override is there for anyone who needs it.