The final product
You can try out my light and dark modes and the switcher I made by interacting with the 🎨 Aa widget in the top right corner of this page, presuming you have first-party JavaScript enabled.
The styles of the dark theme itself
There are several possible approaches:
- Separate, complete stylesheets for each theme: that is, when in light mode, load only
2019b-light.css
, and when in dark mode, load only 2019b-dark.css
.
- A base stylesheet, and specific overrides: that is, assuming that the light theme is the baseline, load
2019b.css
always, and when in dark mode, also load 2019b-dark.css
, which contains rules of equal or greater specificity.
- A base stylesheet with defined theming hooks, and a per‐theme stylesheet: most commonly, assign all themable colours to a CSS custom property, and then set all the values in the particular theme, e.g.
:root { --text-color: black; … }
.
I’m talking primarily about deployment strategies here; preprocessors can make it so you write your code like you’re using the third approach, with variables that the preprocessor will fill in rather than CSS custom properties, but then deploy according to the first approach (e.g. put all your styles in an shared file, then have 2019b-light.css
and 2019b-dark.css
define their theme variables and include it).
I went with the second approach. I have no particularly good reason for having elected to do it that way; it’s just what I ended up deciding to do. Although I used CSS custom properties for the layout of the website, which get used in quite a lot of places and vary by media queries, I didn’t see any particular advantage in doing the same for the colours, given the scope of my website, so I didn’t. For a larger system, the third approach would definitely be better. [2019-09-06: and indeed, I ended up deciding to use CSS custom properties for some of the colours, as I deliberately split less‐common functionality into new files only loaded on individual pages.]
I implemented all this by starting with the light theme, and then when it was close to done, building a dark theme on top, playing whack‐a‐mole by starting with this:
… and adding more styles as necessary, until nothing looked terrible. I did also go through every colour in the stylesheet and either write it into the dark file or write a comment saying I was happy with it already.
(Aside: I really wish that the colour‐modifying function would make it into browsers so that I could do things like add an alpha channel to the current text colour with color(currentColor a(50%))
, or similar with a CSS custom property colour. As it is, my reduced‐alpha link underline colours require me to write out a new text-decoration-color
value every time I change a link’s colour.)
In a couple of places I used an alpha channel to make a single colour work for both light and dark. The theme switcher’s background on hover or focus is #8888
, which makes for a grey of visible but not excessive contrast, on both light and dark backgrounds.
I went for quite high‐contrast in the implementation of both light and dark themes: #111
on #fff
for light, and #d6d6d6
on #000
for dark [These were initially #000
on #fff
for light and #fff
on #000
for dark, but I have since tweaked text colours, reducing text colour to #cccccc
on 2019-08-24 and back to #d6d6d6
on 2020-04-25, all except for the header, sidebar and bold text which got the higher contrast text colour; on 2020-02-03 I followed suit with the light theme, more subtly.]. At some time I may write an article about the three different types of dark interfaces (high contrast for accessibility, low contrast with mixed greys for æsthetics, and true black‐based for situations with low ambient light or on OLED displays); but for now, just know that it is what it is.
There is also a technical reason for me to prefer extreme background colours: the full layout (employed on viewports roughly 700px wide and up) uses a sidebar with a low‐opacity background, which then adds to low‐opacity backgrounds on horizontal bars like figures and asides, so that the reduced contrast adds; reducing the contrast would therefore take more careful calculation and changing most of the colours, even for such a slight shift as from pure black to #111
.
If you’re interested in any other aspects of the theme, you can go and look at it yourself. Browser dev tools are all pretty great these days.
The dynamic theme switcher
Long ago, theme switching support would probably have been handled by a backend that kept track of the theme you wanted to use either via a cookie or via a property on your user account; it could then serve the appropriate stylesheet.
In recent times, browsers have been introducing a coarse‐grained light‐mode/dark‐mode switch, via the prefers-color-scheme
media query. Thus, you can use one of these two approaches to support both light and dark mode, according to the user’s preference as expressed to their browser:
This is a good start, but I wanted to provide users with the ability to choose from inside the website, rather than just in the browser’s preferences.
The initial version of the switcher
My initial implementation required JavaScript to get the dark mode at all, because I had in mind that doing it without JavaScript would require the styles to exist inside a @media
block in the CSS; getting that to work with the ability to choose the colour scheme would be messy, involving wholesale duplication of all the rules.
The JavaScript at that time worked on the href
attribute on a <link>
element: setting it to /2019b-dark.css
if dark mode was to be engaged, or removing it (to disable the stylesheet) if it was not. (This is a very slight simplification of what I implemented, but it’s about right.)
Then, as I approached completion, I started writing this article [I find writing to justify an implementation to be a very successful way of coming up with a better technique. I am known for writing verbose commit messages, and time and time again I have realised something I did wrong, or an alternative that I missed, as I write the commit message. Blog posts work the same way. I strongly recommend these practices to others.], and started justifying why JavaScript was required, and how it could be made unnecessary; and the <link media>
attribute occurred to me: I could twiddle that, instead of the href
attribute. This rapidly proved itself to be a better solution (in terms of flicker‐avoidance, too), so I went with it.
The final version of the switcher
Here is what I deployed, unminified, also with a few trivial alterations for clarity:
Without JavaScript, media="screen and (prefers-color-scheme: dark)"
takes effect, and the user’s preference expressed to the browser prevails.
With JavaScript, it will default to using that, but add a button which allows you to change back and forth, storing the value in Local Storage to be used on subsequent page loads.
For a content site like this, I think this is an excellent solution.
The main thing I’m not eager about with this implementation is that it necessarily entails synchronous JavaScript execution, in order for the first paints to apply the correct theme. If I made it <script src=… async>
, people whose browsers expressed no preference or a preference for light mode, but who had chosen dark mode, would have it flash white as it loaded the page.
The only way of kind‐of fixing that while keeping the backend purely static is to shift this scripting into a service worker and modifying the response there, so that what the browser finally receives after that doesn’t need any JavaScript to run on startup. But that’s a complicated solution that I have no interest in at this time.
Changelog
I keep this article up to date with what I’m doing on the site.
-
2019-08-19: article published.
-
2019-08-24: reduced dark mode text colour from #fff to #ccc, and introduced support for multiple dark‐mode stylesheets. (Before, there was a single hard‐coded <link id=_dark …>
element; the ID allowed me me write _dark.media = '…'
, using one of my favourite code golfing tricks, this Window
named item access behaviour.)
-
2019-09-06: I’ve started using CSS custom properties for some of the colours (but not all), for convenience, now that I’m splitting some less common functionality into separate stylesheets loaded only for particular pages. My scrollbar-color
usage is tied into this decision as well.
-
2019-09-10: for an upcoming article, I wanted to use a different image in light and dark modes, so I made the script work for elements that occur after it in the source. This entailed some code restructuring, but mostly came down to the addition of the DOMContentLoaded
event handler.
-
2021-01-10: added <meta name=color-scheme>
, to opt into more suitable <system-color> defaults in dark mode on platforms that support this. This fixes the default appearance of form controls and scrollbars on such browsers as Chrome. (Firefox supports scrollbar-color
for tweaking the scrollbar colours, though I may well remove that once it supports color-scheme
.)
Note again the approach to progressive enhancement: with no JavaScript, its value will be "light dark"
, meaning that it supports both light and dark modes and will use prefers-color-scheme
to decide; but with JavaScript, it will be reduced to just the appropriate one of "light"
and "dark"
; otherwise, the browser would have system colours always following prefers-color-scheme
, rather than what the user chose.
The need to model three states is why I used a meta tag rather than the color-scheme
CSS property; I could put :root { color-scheme: light dark; }
in the main stylesheet and :root { color-scheme: dark; }
in the dark stylesheet, but I have nowhere to put a :root { color-scheme: light; }
; so it would be either a new kind of magic stylesheet which would only ever be used for this one thing, or the meta tag; I reckon the meta tag considerably nicer.