Lights on
I'm not much of a web dev, but I embrace the occasional quick and dirty Flask-SQLAlchemy hack to scratch my itch and when forced, I try to resort to solve my problems using vanilla.js. So when I committed to freshen up a certain site to make it presentable until the imminent rewrite, one of the loudest requests was to implement a dark mode. So I dug down a rabbit hole to find out how to do that properly, using only minimally invasive tools. Here's what I learned, so you don't have to sit through meandering youtube videos.
The first and simplest thing to do is to tell the browser that your site actually supports color-schemes by applying the appropriate style to the body of your document:
That's it. The browser will now select a dark mode for your site, if the user has configured it system-wide or browser-wide. Thanks for listening, don't forget to like and subscribe.
But … wait! My site looks ugly now. In dark mode, all the white and grey background colours I use still work only on bright backgrounds and on some pages I even still see background-color white.
Styling basics
To roughly follow the suggested default background and font colours for a scheme, you can simply apply the system color keywords, most notably the Canvas and CanvasText colours, which represent the background and font colours corresponding to the color-scheme preferred by your visitors. If you don't want to go for maximum contrast, you can sprinkle in some color-mix towards a medium grey:
You usually use the CSS media query if you need to apply specific styles to each mode:
@media (prefers-color-scheme: light) { a, a:visited { color: blue; } } @media (prefers-color-scheme: dark) { a, a:visited { color: yellow; } }
or even better, for older browsers that do not understand media queries, put the default, usually the light scheme style, outside and let the media query override it.
Colour match images
If you don't want to redo all the illustrations left to you by designers who quit years ago, you can emulate a proper re-design by applying a filter, here's a quick starting point, YMMV:
Let the user decide
Most users of the site were pleasantly surprised at how much easier it was on their eyes, but there were some complaints that even though the user's phone is set to a dark system mode, the web pages should be displayed in light-mode. This can be done by overriding the color-scheme style of the document body, usually in JavaScript by adding a class to the body element and then doing this:
… which I find ugly, as I often disable JavaScript myself, and I don't think that merely setting a color-scheme justifies enabling it. I'd expect a simple checkbox on the web page to allow me to turn on the lights. Fortunately, you can now combine the relational pseudo-class selectors :has and :checked to style the body according to the toggle state of said checkbox:
<input id="override-mode" type="checkbox" aria-label="Switch between dark and light mode"> <label for="override-mode"><span class="hidden">lights</span></label>
… and then
@media (prefers-color-scheme: light) { body:has(#override-mode:checked) { color-scheme: dark; } } @media (prefers-color-scheme: dark) { body:has(#override-mode:checked) { color-scheme: light; } }
Note that your explicit media queries now also have to take into account the potential user override, which can get a bit tedious:
@media (prefers-color-scheme: dark) { body:not(:has(#override-mode:checked)) #header img { filter: invert(50%); } }
Usually you want to hide the actual system checkbox and the label (which we added for readability on older or text browsers), and add visual guides to what the checkbox does, according to the current color-scheme. (Note that the text for the checkbox's label is inside a span that can be hidden, so that the content injected with the :before pseudo-class is still visible):
.hidden, input#override-mode[type="checkbox"] { display: none; } @media (prefers-color-scheme: light) { body:has(#override-mode:checked) { color-scheme: dark; } #override-mode + label[for=override-mode]:before { content: '🌙'; } #override-mode:checked + label[for=override-mode]:before { content: '☀️'; } } @media (prefers-color-scheme: dark) { body:has(#override-mode:checked) { color-scheme: light; } #override-mode + label[for=override-mode]:before { content: '☀️'; } #override-mode:checked + label[for=override-mode]:before { content: '🌙'; } }
And since the Unicode symbols usually come in weird colours that may not match your site's colour scheme, you may want to grayscale() and contrast() tone them down:
Add persistence
Now users were happy to be able to temporarily override their default color-scheme, but whenever they followed a link on the site, the default was restored, which they found surprising, to say the least. Unfortunately, this is as far as we can go without using JavaScript, but it's already a nice fallback to … well, fall back to if the user has JS disabled.
To make the choice persistent, I'd either have to use cookies (which I abhor) or use the browser's localStorage to remember the user's choice. Naturally, I chose the latter. Note that we don't need to store the desired color-scheme. It is enough to remember IF the user has chosen to override the current one. The idea is, that even if the user changes their system defaults in the future, they will usually use the checkbox again to change the override state, if what they can see rendered does not match their expectations. A bool will suffice.
The idea is simple: After the DOM has loaded, but before the page renders, a snippet should inspect the stored override flag and then set the checkbox element accordingly. Unfortunately, waiting for a non-inlined script to load leaves the page briefly in a state where it doesn't yet know the override choice, sometimes resulting in bright, retina-burning flashes when the page loads. So I decided to inject the script directly into the head of the page:
<head> <script> (function() { document.addEventListener("DOMContentLoaded", function() { if (localStorage.getItem('override-prefers-color-scheme', false)) document.getElementById("override-mode").checked = true; }); })(); </script> </head>
For example, the default JavaScript pipeline can include the event listener for the checkbox state change:
$(document).ready(function(){ document.getElementById("override-mode").addEventListener("change", () => { if (document.getElementById("override-mode").checked) localStorage.setItem("override-prefers-color-scheme", 1); else localStorage.removeItem("override-prefers-color-scheme"); }); });
And that's about it.