Getting Dark Mode Working on Neocities

While I haven't quite finished some preparations for this site, I've been itching to share stuff anyways, so here's a short bit on how to add a dark mode to your static site. Besides allowing a visitor to explicitly choose a theme, I also take note of a third option where the site automatically matches the browser's theme. You're going to need an okay grasp on CSS and Javascript to understand what's going on. If you're still learning, I beg of you to use the tutorial on MDN instead of W3Schools or some other site if you aren't already.

The CSS

There are four cases we must take into account when defining our CSS declarations: explicit light mode, explicit dark mode, browser light mode, and browser dark mode. The explicit cases can be handled with a class and a media query for the browser theme. If you want to add a class for the media queries, you should also add it in the HTML (e.g. <body class="browser">) so that the theme is selected by default just in case the Javascript fails.

body.dark {
	background-color: black;
	color: white;
}

body.light {
	background-color: white;
	color: black;
}

@media (prefers-color-scheme: dark) {
	body {
		background-color: black;
		color: white;
	}
}

@media (prefers-color-scheme: light) {
	body {
		background-color: white;
		color: black;
	}
}

Even with only two declarations per colour scheme, the need to duplicate them means this will get very long very quickly, and it becomes more error prone to modify since we may forget to change one of the duplicates. You could be wise and devise a solution with Javascript, or you can be me and wing it with CSS without thinking it through. Here's a solution that's even longer and only partially solves the error-proneness. On the plus side, the theme will default to the browser's if the Javascript doesn't execute for some reason.

The way I set things up was to use variables. For those not aware, CSS variables allow values to be referenced later. The variables have two layers of indirection. The first layer defines the actual values for each colour scheme, and the second layer selects which value should be used. There are multiple choices for the second layer which are selected based on the class or media query.

body {
	--dark-mode-body: black
	--dark-mode-text: white;

	--light-mode-body: white;
	--light-mode-text: black;

	background-color: var(--body-color);
	color: var(--text-color);
}

body.dark {
	--body-color: var(--dark-mode-body);
	--text-color: var(--dark-mode-text);
}

body.light {
	--body-color: var(--light-mode-body);
	--text-color: var(--light-mode-text);
}

@media (prefers-color-scheme: dark) {
	body {
		--body-color: var(--dark-mode-body);
		--text-color: var(--dark-mode-text);
	}
}

@media (prefers-color-scheme: light) {
	body {
		--body-color: var(--light-mode-body);
		--text-color: var(--light-mode-text);
	}
}

A single variable corresponds to a single colour with this CSS structure, but the tradeoff is the increased complexity that makes adding colours more difficult.

The Javascript

Somewhere in your HTML, there should be three <button> elements with the attribute type="button": one for light mode, one for dark mode, and another for the browser. If your site has a consistent header or footer, that will likely be the best place for them. Giving them an ID isn't necessary, but it makes modifying them in Javascript easier. Each button will get an event listener that will change the <body>'s class. If you need a refresher on events, here's the MDN article.

// Light mode example
document.getElementById("light-mode-button").addEventListener("click", () => {
	document.body.className = "light";
});

I used className here since switching out the theme class is more straightforward. Because modifying className applies to the entire class attribute value, this won't work if you have other classes on your <body>. Consider using classList in that situation.

A mechanism also needs to be added for the site to remember which theme the visitor chose. The Web Storage Application Programming Interface (API) will be suitable for this task. For the site to remember the theme setting after the page is closed, the localStorage object will be used. Values can be stored with the setItem() method, which associates a key name with the value. The value can then be retrieved at a later time by passing the key name to the getItem() method.

const initialTheme = localStorage.getItem("theme") ?? "browser";
document.body.className = initialTheme;

document.getElementById("light-mode-button").addEventListener("click", () => {
	document.body.className = "light";
	localStorage.setItem("theme", "light");
});

// ...

The API returns null if a key was not added, which will occur when a visitor visits a page for the first time. The two question marks, called the nullish coalescing operator, provide a default value in that case. The second line will set the class of <body> when the page first loads.

Because the script requires accessing elements in the HTML, it should be executed after the browser has finished parsing the HTML file. My preferred way is to put it in the <head> with the defer attribute.

<head>
	...
	<!-- theme.js is just an example. You can put the source wherever you wish. -->
	<script src="/theme.js" defer></script>
</head>

Anyone who's been designing their site with accessability guidelines in mind will also want to implement the aria-pressed attribute for each button.

Media Queries via Javascript

My brilliant CSS probably isn't an appealing option for some, so here's how to do it in Javascript. If you choose to use this method, the CSS only needs to define the light and dark mode classes and not the media queries, and the HTML must have one of the two classes explicitly set in case the Javascript fails.

Later Correction: I assumed that both themes needed to be part of a class, but I forgot that it's perfectly fine for one theme not to be in a class, as the classed theme will have higher precedence if active. That means, say, the CSS for the light theme can be defined in the body selector, and the dark theme in the body.dark selector. This set up alleviates the need to add a class directly in the HTML.

const initialTheme = localStorage.getItem("theme") ?? "browser";
const isLightModeBrowser = matchMedia("(prefers-color-scheme: light)").matches;
const browserTheme = isLightModeBrowser ? "light" : "dark";
document.body.className = initialTheme === "browser" ? browserTheme : initialTheme;

document.body.getElementbyId("browser-mode-button").addEventListener("click", () => {
	document.body.className = browserTheme;
	localStorage.setItem("theme", "browser");
});

// ...

I should probably explain what's going on. The first line is the same as before. The second line uses a media query to check if the browser's theme is light or dark. The isLightModeBrowser variable is a boolean. If the browser theme is light mode, then the value is true. Otherwise, the browser theme is dark mode, so the value is false. The third line converts the boolean value into a string variable, browserTheme, that can be used for setting classes. The weird question mark colon syntax is the conditional operator.

Whenever the browser theme needs to be set, the browserTheme variable should be used. For setting the class of <body> on page load, a conditional operator is set up so that only "light" and "dark" are returned. Inside the event listener for the browser mode button, document.body.className is assigned the browserTheme variable and not "browser".

Fixing the Browser Theme Flash

If you've managed to follow along, then everything should be working. However, if you refresh the page with the theme set opposite to the browser default, you may have noticed the page very briefly show the browser theme. This is pretty nasty for those using dark mode since they might get flashbanged every time they visit a new page. The reason why this is happening is because the browser begins rendering the page before the script adds the class to <body>. The theoretical solution is to add the script inside <body> at the very end.

	<body>
		...
		<script src="/theme.js"></script>
	</body>
</html>

The idea is that the browser isn't supposed to begin rendering until it's done reading the HTML. In theory, because the browser has to execute the script before it can continue, <body> will have its class set before it's rendered. In reality, browsers are actually extremely eager to get any stuff onto the screen, so the browser will flash its default theme while it's partially through the HTML, and this solution won't work even though we technically followed the rules. Therefore, the class needs to be changed before the browser has a chance to render anything.

<head>
	...
	<script src="/theme.js" defer></script>
</head>
<body>
	<script>
		const initialTheme = localStorage.getItem("theme") ?? "browser-mode";
		document.body.className = initialTheme;
	</script>
	...
</body>

The lines that initially change the <body>'s class are removed from the source file and placed right at the beginning. While you could put the code in a separate file, it's small enough to leave out in the open. Based on my tests with Edge (Microsoft branded Chrome) and Firefox, the browser theme flash hopefully shouldn't happen anymore . If you're using a browser that still has this issue, then, uh, I've got nothing. ☹️