web dark mode toggle (the correct* way)

*according to Tess

I just implemented the light/dark toggle for BlogPlat, which runs this blog. there's a suprising amount of subtle complexity in this simple feature, so I'm writing this in case it's useful to anyone.

state machine

modern browsers expose the browser/system setting via window.matchMedia('(prefers-color-scheme: dark)'). we want to respect this by default.

we also want to allow the user to toggle dark mode on our site in particular. we could expose three modes (light, dark and respect system settings), but that might be confusing to users. instead, we will have a button that always toggles between light and dark. when the user requested setting is the same as the system default, the system default is used (and the displayed theme changes if the system default changes later). if the user selects the other mode than that mode is forced (and system default changing does not effect it).

state machine

persistence & listeners

we save the user's preference (light, dark or default) in local storage so it persists and applies to all pages on our site.

we listen to changes of both system preference and local storage, so OS/browser settings changes and changes from other open pages apply instantly.

applying the mode

we add the light-mode class to the body element when light mode is active, and remove it when dark mode is active. we then use CSS variables to change colors on the page based on the class:

:root {
  --text: white;
  --background: black;
}

.light-mode {
  --text: black;
  --background: white;
}

body {
  color: var(--text);
  background: var(--background);
}

eliminate flash on load

we don't want it to momentarily flash the wrong color on load, so our script uses addEventListener('DOMContentLoaded', () => { ... }) instead of addEventListener('load', () => { ... }).

code

you can find the JavaScript file that implements all this here. it can be included in <head>. you call onLightModeToggled() when the user toggles the mode (typically in the onclick of the toggle button) and isLightModeEffective() if you need to know the current state (returns true for light and false for dark).