From 72e7a290f11ddb9a985febd67ce617c98e1ec935 Mon Sep 17 00:00:00 2001 From: Pagwin Date: Fri, 5 Sep 2025 17:30:14 -0400 Subject: [PATCH] wrote the post and minor demo changes --- posts/platonically-ideal-light-dark-toggle.md | 112 +++++++++++++++--- static/demos/light-dark-demo-2/index.html | 2 +- static/demos/light-dark-demo-4/index.html | 7 +- 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/posts/platonically-ideal-light-dark-toggle.md b/posts/platonically-ideal-light-dark-toggle.md index eb69017..1b91d63 100644 --- a/posts/platonically-ideal-light-dark-toggle.md +++ b/posts/platonically-ideal-light-dark-toggle.md @@ -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. - - - ## 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. diff --git a/static/demos/light-dark-demo-2/index.html b/static/demos/light-dark-demo-2/index.html index ad00583..eb41341 100644 --- a/static/demos/light-dark-demo-2/index.html +++ b/static/demos/light-dark-demo-2/index.html @@ -46,7 +46,7 @@ outline: turquoise 5px solid; } - -

Don't copy this code at all, it is for demo purposes only and is terrible code.
Note: if - you - hard refresh you will get your system preference of light/dark due to this demo being powered by a service - worker

+

Don't copy this code at all, it is for demo purposes only and is terrible code.

+

Note: This demo makes use of a service worker meaning it won't work for hard reloads or if you have + javascript disabled