wrote the post and minor demo changes
This commit is contained in:
parent
e33c323ca8
commit
72e7a290f1
3 changed files with 102 additions and 19 deletions
|
@ -1,41 +1,125 @@
|
|||
---
|
||||
title: "Making a platonically ideal light/dark theme toggle on the web"
|
||||
|
||||
description: ""
|
||||
description: "And exploring other solutions along the way"
|
||||
|
||||
date: "2025-08-20"
|
||||
|
||||
draft: true
|
||||
draft: false
|
||||
|
||||
tags: []
|
||||
---
|
||||
|
||||
Many blogs and other sites have a way to toggle between their light and dark themes.
|
||||
However many of these toggles are... not ideal, making various tradeoffs.
|
||||
So I thought it'd be interesting to explore these tradeoffs and throw my own hat into the ring with a potentially better solution.
|
||||
|
||||
<a href="https://xkcd.com/927/"><img src="https://imgs.xkcd.com/comics/standards_2x.png"></a>
|
||||
|
||||
## What Does it Mean to be Ideal?
|
||||
|
||||
For the purposes of this article an ideal theme toggle has the following properties.
|
||||
|
||||
1) The page will have one of two states at all times, light and dark
|
||||
2) On first page load the state should match `@media (prefers-color-scheme)`
|
||||
3) When the toggle is clicked the light/dark state will be changed
|
||||
4) The current state of light/dark should persist between reloads
|
||||
5) The visible theming of the page should match the current light/dark state
|
||||
6) 5 should be accomplished as inconspicuously as possible
|
||||
7) The above should be done with progressive enhancement
|
||||
8) The above should be done with minimal impact on browser optimizations
|
||||
|
||||
The purpose of the first four points should be reasonably apparent.
|
||||
|
||||
If the page can't be in a state of light or dark then what's the point of the toggle between the two.
|
||||
Having the page have both and not match `prefers-color-scheme` on the initial load is an anti-pattern.
|
||||
Having the button change the state is the entire purpose of what we're trying to do.
|
||||
If the state doesn't persist then using the button becomes a chore that needs to be done on every page load in order to get any benefit.
|
||||
And the page visually matching the state is a fundamental part of this being for theming.
|
||||
|
||||
The remaining points are there to address shortcomings that occur with current implementation of the first 5 points.
|
||||
|
||||
Point six exists to address the "flash of white" from JavaScript which affects the styling taking a moment to load
|
||||
|
||||
Point seven is because we want to give the best experience possible even under suboptimal conditions.
|
||||
|
||||
Point eight is so we can avoid breaking browser optimizations to our site's load time.
|
||||
|
||||
## Demo Caveat
|
||||
|
||||
It's expected that most readers will be reading this article from a decently powerful device with a good internet connection on a modern browser and are unlikely to be disabling scripts.
|
||||
However most of the "issues" with these various solutions stem from circumstances where one of those circumstances are false.
|
||||
Furthermore issues that fall under points 6 and 8 only become particularly apparent on pages with some "girth" (dare I say bloat) to them, as such it's expected that all demos, being rather lightweight, will at first glance pass certain criteria even if they don't.
|
||||
|
||||
So I am relying on some amount of the reader taking me at my word regarding certain implementations failing some criteria.
|
||||
|
||||
## The Obvious Way
|
||||
|
||||
[demo](/static/demos/light-dark-demo-1)
|
||||
|
||||
The obvious way to attempt a toggle between light and dark mode is
|
||||
The obvious way to attempt a toggle between light and dark mode is to just have a script (asynchronously loaded for point 8) with a button which runs a function to update `localStorage` and update the DOM in a way which changes what CSS is applied.
|
||||
Then on reload the Javascript checks `localStorage` and does the DOM change immediately if needed.
|
||||
|
||||
## A Suboptimal Solution
|
||||
Unfortunately this solution runs afoul of points 6 and 7.
|
||||
To be more specific in some circumstances we can get a "flash of white" effect where the default briefly shows before being overwritten by the Javascript which goes against point 6.
|
||||
Furthermore if we don't have Javascript we have a useless button and might even fail on point 2 (depending on implementation specifics).
|
||||
|
||||
A trick from from [](https://www.joshwcomeau.com/react/dark-mode/)
|
||||
## A Tradeoff Between Points 6 and 8
|
||||
|
||||
This section is based on the [solution Josh Comeau uses](https://www.joshwcomeau.com/react/dark-mode/).
|
||||
|
||||
[demo](/static/demos/light-dark-demo-2)
|
||||
|
||||
For those who haven't read Josh's article what we're doing is preventing the browser from rendering the page until our Javascript which changes the DOM has run.
|
||||
In Josh's version this is done with a `script` tag which blocks processing of the page below the script tag until the script tag is fully ran, updating the DOM to match the desired style.
|
||||
|
||||
## An Ideal Stateless Toggle
|
||||
This of course comes at the cost of some amount of performance as blocking the browser from doing work means that the work gets done later.
|
||||
|
||||
[demo]()
|
||||
We can mitigate this a bit by adding `blocking="render"` to the script instead of having the script be fully blocking but this is still not ideal.
|
||||
|
||||
## An ideal solution
|
||||
For the curious read, [this](https://csswizardry.com/2024/08/blocking-render-why-whould-you-do-that/) for more info on what `blocking="render"` does.
|
||||
|
||||
## A CSS Only Solution
|
||||
|
||||
[demo](/static/demos/light-dark-demo-3)
|
||||
|
||||
As a bit of a tangent there's an interesting solution available to us via `:has` and `:checked`.
|
||||
|
||||
We just have the checkbox being checked toggle us away from the system setting to the alternate styling using a selector like.
|
||||
|
||||
```css
|
||||
:root:has(#light-dark-toggle:checked) {...}
|
||||
```
|
||||
|
||||
With the body setting whatever css variables and element attributes to their toggled variants.
|
||||
|
||||
This version doesn't represent its light and dark states directly it derives them from the system setting and the toggle state but the representation was always an implementation detail.
|
||||
Readers who went into dev tools for the prior demos will have already noticed that rather than having light and dark states explicitly in the CSS I just have a toggled class which swaps to the alternative from the system setting.
|
||||
In those examples that was to keep the CSS and Javascript simple but in this case it is simply the only option available to us.
|
||||
|
||||
Unfortunately this does fail at point 4, this solution can't persist the checkmark state without either an additional click to submit a form or Javascript.
|
||||
|
||||
Speaking of forms though.
|
||||
|
||||
## An (Almost) Ideal Solution
|
||||
|
||||
[demo](/static/demos/light-dark-demo-4)
|
||||
|
||||
If Javascript running once the page starts loading doesn't work because Javascript is expensive to run and violates progressive enhancement and a solution with CSS can't persist across reloads then what can we do?
|
||||
|
||||
Well there is one way we can change the DOM without Javascript, and there is one way we can persist state without Javascript…
|
||||
|
||||
The backend side of web development is a pathway to many abilities some consider to be unnatural.
|
||||
|
||||
To be more specific we can make an endpoint which sets/unsets a cookie before redirecting back and then have anything which gives back a full HTML page back as a response make whatever changes correspond to the theme change, or we can swap out the stylesheets we give the user, if we're feeling deranged.
|
||||
|
||||
Then we can make an HTML form which goes to that endpoint, with the current page URL as a hidden form field because unfortunately browsers don't give the referer header when submitting forms.
|
||||
|
||||
Then to give users with Javascript a better experience we can have the script intercept the form submission to just make the DOM changes itself and send the HTTP request in the background.
|
||||
|
||||
Mark the form as hidden and show it with CSS among other minor changes and this solution hits every point perfectly.
|
||||
|
||||
That said the demo above doesn't do that because
|
||||
|
||||
1) It's meant as a demonstration of the technique not a perfect implementation
|
||||
2) I don't want to have a demo which depends on a proper backend because I intend to keep this blog on static site hosting, like Github pages (the current choice), for the foreseeable future and because having a demo depend on some deployment I will never care about seems like a good way to have the demo break
|
||||
|
||||
As such the demo uses a service worker and because service workers can't access cookies I use IndexedDB, furthermore because service workers can't access DOM APIs the DOM manipulation is done by a hard coded string replacement.
|
||||
|
||||
## Why Don't I Have a Light/Dark Toggle Button on This Blog?
|
||||
|
||||
Because doing it correctly is more effort than it's worth, I don't want to do it incorrectly, and for most people their system preference will already match what they want.
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
outline: turquoise 5px solid;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
<script blocking="render">
|
||||
function toggleTheme() {
|
||||
if (!document.body.classList.contains("toggled")) {
|
||||
document.body.classList.add("toggled")
|
||||
|
|
|
@ -90,10 +90,9 @@
|
|||
|
||||
await navigator.serviceWorker.register("sw.js");
|
||||
</script>
|
||||
<p>Don't copy this code at all, it is for demo purposes only and is terrible code. <span class="red"><br>Note: if
|
||||
you
|
||||
hard refresh you will get your system preference of light/dark due to this demo being powered by a service
|
||||
worker</span></p>
|
||||
<p>Don't copy this code at all, it is for demo purposes only and is terrible code.</p>
|
||||
<p class="red">Note: This demo makes use of a service worker meaning it won't work for hard reloads or if you have
|
||||
javascript disabled</p>
|
||||
<form action="./light-dark-toggle" method="get">
|
||||
<input type="hidden" name="ref" value="https://pagwin.xyz/static/demos/light-dark-demo-4/">
|
||||
<input id="full-demo" type="submit" value="Fully enhanced demo">
|
||||
|
|
Loading…
Reference in a new issue