My dark theme implementation

I decided to add a dark theme as part of my new website. It turns out that the implementation of such a thing is not quite trivial, so here’s some information about how I achieved it.

The final product

You can try out my light and dark modes and the switcher I made by interacting with the 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:

  1. 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.
  2. 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.
  3. 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:

:root {
	background: #000;
	color: #fff;
}

… 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:

Using an inline media query with @media to support a dark theme, without the ability to switch theme manually.

<style>
	/* Base (light mode) styles */

	@media screen and (prefers-color-scheme: dark) {
		/* Dark mode styles */
	}
</style>

Using multiple stylesheets with a <link media> media query to support a dark theme, without the ability to switch theme manually.

<link rel=stylesheet href=/2019b.css>
<link rel=stylesheet href=/2019b-dark.css
	media="screen and (prefers-color-scheme: dark)">

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:

The complete markup for functional dark mode support, defaulting to the general preference the user has expressed in the browser, but also switchable via buttons if JavaScript is enabled. Also included: the serif/sans‐serif font switcher which I employ.

<meta name=color-scheme content="light dark">
<link rel=stylesheet href=/2019b-dark.css
	media="screen and (prefers-color-scheme: dark)">

<!-- The magic works for more than one stylesheet, just needing this
media attribute; pages needing more than the global stylesheet and
`currentColor` offer can therefore add their own styles. -->
<style media="screen and (prefers-color-scheme: dark)"></style>

<details id=themer style=display:none>
	<summary aria-label=Theme
		title="Change theme (light/dark mode, serif/sans-serif font)">
			<b aria-hidden=true>🎨 A<i>a</i></b>
	</summary>
	<div>
		<script>

const themerDiv = document.currentScript.parentNode;
themerDiv.parentNode.style.display = '';

function switcher(prefName, offLabel, onLabel, suffix, defaultValue, apply) {
	let enabled;
	const button = document.createElement('button');
	function sync() {
		apply(enabled);
		// Update the button label
		button.childNodes[1].data = enabled ? offLabel : onLabel;
		// Try to persist to storage, iff we’re effecting a deliberate change
		try {
			if (localStorage.getItem(prefName) || enabled != defaultValue) {
				localStorage.setItem(prefName, enabled);
			}
		} catch (e) {}
	}

	themerDiv.append(button);
	button.append('Switch to ', '', suffix);
	button.addEventListener('click', () => {
		enabled = +!enabled;
		sync();
	});
	try {
		enabled = localStorage.getItem(prefName);
	} catch (e) {}
	// enabled should now be null, '0' or '1'. Fill in the default value,
	// coerce what may be something like '0', '1', true or false to 0 or 1,
	// and act on it.
	enabled = +(enabled || defaultValue);
	sync();
	return sync;
}

let darkModeDefault = 0;
try {
	darkModeDefault = matchMedia('(prefers-color-scheme: dark)').matches;
} catch (e) {}

let stylesheetElements = document.querySelectorAll(
	'[media="screen and (prefers-color-scheme: dark)"]',
);

const syncDarkMode = switcher(
	'dark', 'light', 'dark', ' theme', darkModeDefault, enabled => {
		document.querySelector('meta[name=color-scheme]').content = enabled ? 'dark' : 'light';
		stylesheetElements.forEach(element => {
			element.media = enabled ? 'screen' : 'not all';
		});
	});

switcher('sans', 'serif', 'sans-serif', ' body font', 0, enabled => {
	document.documentElement.classList[enabled ? 'add' : 'remove']('sans');
});

// If anything is in the source after this script, handle it.
addEventListener('DOMContentLoaded', () => {
	stylesheetElements = [
		...stylesheetElements,
		...document.querySelectorAll(
			'[media="screen and (prefers-color-scheme: dark)"]',
		),
	];
	syncDarkMode();
});

		</script>
	</div>
</details>

<!-- Sadly video and audio sources can’t use `media`, but picture can! -->
<picture>
	<source src=screenshot-dark.png
		media="screen and (prefers-color-scheme: dark)">
	<img src=screenshot-light.png>
</picture>

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.