A long, long time ago I wrote an article introducing the dark mode for websites.
At that time I wanted to add a simple light/dark mode toggle to the site. The idea was:
- During the day (6 a.m. to 6 p.m.) use pure CSS
@media (prefers-color-scheme: dark)to detect whether the user's device has dark mode enabled. - At night (after 6 p.m. to 6 a.m. the next day) load another CSS file meant for nighttime.
It looked straightforward, simple, and convenient, and it solved the problem of being blinded when opening the page at night. At first it worked pretty well, but after a while I discovered two serious issues:
- This approach required long-term maintenance of two CSS files—one for day and one for night. Every time I needed to add a new feature or remove outdated styles, I had to update both files. The CDN cache also had to be purged for both files to see the full effect. Every few months I had to compare the two files to see if any styles were missing.
- At night, dark mode couldn't be switched back to light mode. If I tweaked styles at night, I had to manually change the
<link>content in the console to reload the daytime CSS.
So these past few days I set out to upgrade the light/dark toggle once more.
Ver 0.1
When I first rewrote the toggle, I focused on the problem that "dark mode at night can't be switched back to light mode", so I thought of using localStorage to store the current page theme. Light, Dark, System correspond to light mode, dark mode, and follow-system respectively. On first load it uses follow-system, and you can switch from follow-system to light or dark. Then just load light.css, dark.css, or style.css accordingly.
<link rel="stylesheet" id="linkCSS"/>
<script>
if (localStorage.getItem("Lighting")) {
if (localStorage.getItem("Lighting") === 'Light') document.getElementById('linkCSS').href = 'https://cdn.vinking.top/css/light.css';
else if(localStorage.getItem("Lighting") === 'System') document.getElementById('linkCSS').href = 'https://cdn.vinking.top/css/style.css';
else if(localStorage.getItem("Lighting") === 'Dark') document.getElementById('linkCSS').href = 'https://cdn.vinking.top/css/dark.css';
}else{
localStorage.setItem("Lighting", "System");
document.getElementById('linkCSS').href = 'https://cdn.vinking.top/css/style.css';
}
</script>
Put the above in <head> so it runs before rendering and loads the correct CSS. The toggle button works the same way: change localStorage to switch modes. If you click while in follow-system, first use window.matchMedia('(prefers-color-scheme: dark)').matches to detect the device's current theme and then switch to the opposite.
Local testing felt fine, but once deployed I found problems:
- Still had to maintain multiple CSS files. This version even added one more, so adding button styles became painful×3.
- Didn't consider CSS vs. JS loading priority. CSS is Highest priority, script is High priority, so other CSS finishes first, then the script runs and starts loading the key CSS file. Add CSS load time and there's a flash of unstyled content, hurting the experience.
After tweaks came the next version.
Ver 0.6
The only difference among light.css, dark.css, and style.css is the :root variable values. So I could put all three :root blocks into one file in a specific order, and just add the corresponding class to <html>. Leveraging the CSS cascade—later rules override earlier ones—I wrote this order:
:root{
...
}
@media (prefers-color-scheme:dark){:root{
...
}}
.Light{
...
}
.Dark{
...
}
On first load in follow-system, <html> has no class, so both the base :root and, if the device is in dark mode, the @media (prefers-color-scheme:dark) :root apply. Switching to dark or light adds class Dark or Light, whose later rules override the earlier :root.
Now <head> can directly reference the CSS file; no JS decision needed. Opening DevTools' Performance tab shows that when the script runs, Timings haven't reached First Paint (FP), so the page paints with the correct theme immediately, avoiding the flash.
Ver 1.0
In practice, after clicking the toggle button the background might still be missing for a moment while it loads. Preloading solves this: add an onmouseover handler to the button that injects <link rel="prefetch" href="">, and remember to set XXX.onmouseover = null to avoid repeated triggers.
With that, the core light/dark toggle framework is complete; only minor fixes like reloading syntax highlighting remain.
P.S. Small rant: why is it getting harder to find nice landscape images on Pixiv these days? My feed is all portrait pics....