i am schulz
Three colorful chameleons sitting on an off-screen object and staring to the left. The first one is purple, the second red, and the third one yellow.

Lights Out! - Overengineering a Dark Mode

With the upcoming launch of the redesigned Safari themes are all the rage. It’s not a new feature. Chrome on Android had it for years, Vivaldi brought it to Desktop, Apple brings new attention to it.

Theming can be much more than just providing a meta tag, though. Let’s take a close look at how users can customize a website to their own preferences and how to implement them in a clean, fast and modern way and wrap everything up in a small, clean boilerplate template.

A good technique

Implementing themes can be a challenging technical task. We want the theme to be available as fast as possible, preventing any flashes of wrong styles. We also want to consider all the hints the user gives us to select the most fitting theme. We want our users to be in control and able to select a theme for themselves. That’s a lot of variables to consider. Maybe it’s time to stop thinking about themes as monolithic stylesheets and explore some more fluid choices. Think of it the same way Responsive Webdesign provided a more fluid solution to strictly separated solutions for large and small screens.

Automatic dark mode

The instruction color-scheme comes as both a media query and a meta tag. It tells the browser which color schemes are supported, which is preferred, and which is enforced and the browser reacts by applying sensible defaults.

CodePen (ID: zYwbWMK) Activating this Feature allows CodePen to place cookies and communicate with CodePen's servers.
(more info)

This promises to be a great way to create minimal dark themes or provide a boilerplate for more intricate designs since we don’t have to write our own dark styles for general UI elements like buttons and input fields anymore. It’s part of the color scheme spec, which for now accepts only dark and light and no custom color themes. But that doesn’t mean there won’t be more options in the future. Both the intention to expand this query and the fact that its values have meaningful names should prompt us to use this media query as a list of options rather than an on/off switch for dark mode.

It’s currently supported only by Chrome and Safari, while only Chrome sets usable defaults across all elements. Safari won’t change the style of input elements, nor the background color, which can lead to white-on-white text.

In any case, some more styling with more resilient methods is required.

Media Queries

Just as Media Queries were the secret sauce behind Responsive Design, they take the same role here. That means, we can let our website react to individual properties that are exposed to it by the browser.

User Preferences

The prefers-color-scheme media query determines what theme the user’s device is set to. Like the color-scheme instruction, it only accepts dark and light as options. While color-scheme sets a certain scheme on the document or a selector, prefers-color-scheme reacts to a scheme.

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

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

High contrast mode

The media query to react to High Contrast Mode is sadly split up between Apple and the rest.

When a user with enabled high contrast mode visits our website from a Windows or Android system, the browser simply disables all our color choices and forces its own, ensuring accessible colors at all costs. We can only react to that by querying forced-colors. Since all color instructions are void at this point, the motivation behind this media query is layout. MDN lists a good example that changes a dashed to a solid line.

Apple does no such drastic methods. Safari can only react to prefers-contrast by matching it against more or less. At this point, we have to implement a high contrast theme ourselves and do the right thing by maxing out contrasts manually. I’d even go as far and say that this is one of the rare chances to use !important liberally.

@media (prefers-contrast: more) {
	* {
		background: black !important;
		color: white !important;
	}

	*::selection {
		background: cyan !important;
		color: black !important;
	}

	a,
	a *,
	a:hover,
	a:hover *,
	a:active,
	a:active * {
		color: yellow !important;
		border-color: yellow !important;
	}

	a:visited,
	a:visited * {
		color: greenyellow;
		border-color: yellow !important;
	}

	button {
		background: black !important;
		color: white !important;
		border: 0.2ch solid cyan !important;
	}
}
A screenshot of iamschulz.com. An article page. The contrasts are maxed out. The background is black, texts are white, links are yellow, visited links are yellowish-green.

Custom Properties

Combining Media Queries with Custom Properties enables us to write truly adaptive styles. We could overwrite default styles with media queries just like classic responsive Layouts do, but that would already be overkill. We end up writing complex media queries and potentially introduce side effects with the layout when we only want to change some colors. Custom Properties are the perfect tool for that because they can change depending on their context. That way we can use the cascade to apply the colors we want in each condition and inherit them to all further selectors.

:root {
	--background-color: white;
	--font-color: black;
}

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

body {
	background: var(--background-color);
	color: var(--font-color);
}

But we can do even more. Just like CSS layouting shifts from hardcoded elements to programmatic layout systems with tools like flex, grid, and Container Queries, we can create color systems with the help of calc and hsl.

The classic color formats hex and RGB calculate colors by mixing the three base colors red, blue, and green. While this gives us control over exact color values, controlling certain aspects of color is quite hard. The HSL format addresses colors by their hue, saturation, and lightness properties. Lightness comes in very handy for this matter. It allows us to programmatically different versions of a color archetype, like dark and light versions, contrasted and even graded ones, while still being on a consistent tone. If you used a CSS preprocessor like Sass or Less, you might be familiar with their color functions. We’ll do just that in plain CSS now.

:root {
	--theme-hue: 220deg;
	--theme-sat: 10%;
	--theme-lit: 100%;
}

@media (prefers-color-scheme: dark) {
	root {
		--theme-lit: 20%;
	}
}
body {
	--background-color: hsl(
		var(--theme-hue),
		var(--theme-sat),
		var(--theme-lit)
	);

	--font-color: hsl(
		var(--theme-hue),
		var(--theme-sat),
		clamp(0%, calc(100% - (var(--theme-lit) - 47%) * 1000), 100%)
	);
	/* 47% seems a good threshold for the blue tone at 220deg */

	background: var(--background-color);
	color: var(--font-color);
}

Now we can control the entire theme from a few variables and tweak all our theme colors with global hue, saturation, and lightness values. Better yet, we can introduce automatic switches. --font-color sets itself to a dark or light shade of the theme color based on the global lightness value. We’ll have automatic font contrast now. Those kinds of switches can come in handy for all sorts of things.

We need to keep in mind that our color switch needs to be adjusted for each hue and saturation value to keep the contrast to the background color accessible. HSL’s visual lightness is not consistent as we change the other values. A yellow hue will always be brighter than a blue one, even at the same lightness values.

The tool to fix that would be the LCH (Lightness Chroma Hue) color format. It keeps the visual brightness consistent. We’ll see that when changing the hue at a constant saturation and lightness, then converting the resulting color to grayscales. The HSL values vary a great deal more than the LCH ones, making it harder to determine accessible font contrasts.

:root {
	--background-color: lch(
		var(--theme-lit),
		var(--theme-sat),
		var(--theme-hue)
	);

	/* needs less adjustment */
	--font-color: lch(
		clamp(0%, calc(100% - (var(--theme-lit) - 47%) * 1000), 100%),
		var(--theme-sat),
		var(--theme-hue)
	);
}

I opted to use HSL, because LCH is still in its experimental stage as of now and its browser support is nonexistent. Lea Verou has written a very insightful article on how it works.

User Preferences

Using only media queries, our design can adapt to the user’s system. If their browser operates in dark mode, so will the website. But sometimes users want to use the other design nonetheless. An example: the OLED screen of a mobile phone consumes less energy when displaying darker colors. Activating dark mode system-wide is a sensible decision. However white text on a black background is harder to read - especially in sunlight, where mobile phones sometimes end up. The user would want to enable the light theme for this specific website. We need to give our users a choice. Ideally, we would expose all options:

CodePen (ID: WNjmGvN) Activating this Feature allows CodePen to place cookies and communicate with CodePen's servers.
(more info)

Because we set the theme with a data attribute instead of relying only on media queries now, we can expand our list of themes now. I included a cherry red one because the cherry season is ending and it’s gonna be a while until we have fresh ones.

So, this solution will set the theme to the desired option, but it will not persist if the user refreshes or navigates to the next page. Where there’s persistent states, localstorage is not far:

const switchTheme = (e) => {
	const theme = e.target.value;
	document.body.dataset.theme = theme;
	localStorage.theme = theme;
};

The JavaScript for the switch itself can be deferred. Users most likely don’t need to switch themes as soon as the browser paints the page. However, we do need the information on which theme the user has selected in order to prevent a Flash of Inaccurate Color Theme (or FART, as Chris Coyier likes to abbreviate).

I consider render-blocking JavaScript mostly evil because it can quickly delay the Largest Contentful Paint of a website. But if we’re careful about what we do (as in don’t use loops, querySelectors, or other slow operations), we can sneak in a few functions before the render while keeping the impact negligible.

// inlined inside the document <head>, so it's render blocking
if (window.localStorage && localStorage.theme) {
	document.body.dataset.theme = localStorage.theme;
}

Tada! We have an immediate, persistent, and lightweight theme switch.

More than just CSS

CSS takes the lion’s share of providing a theme, but we can do more to provide a consistent UX.

Themed Images

Now we can provide a dark theme to go easy on the user’s eyes in dim light conditions, but then blast them with full-screen graphics with a white background. That’s not a nice UX. Luckily, image source sets can react to media queries just the like CSS does, so we can provide toned-down graphics for dark mode users.

<picture>
	<source srcset="cat-keytar-night.jpg" media="(prefers-color-scheme:dark)" />
	<img
		srcset="cat-keytar-day.jpg"
		alt="A cat wearing a 90's retro jacket with a keytar"
	/>
</picture>

We only have access to media queries that way, so there’s no way to force a theme by setting a data attribute.

Themed SVGs

SVGs can be integrated into themes even better than pixel-based images because they’re inherently code and can be styled by CSS. Everything we built above for the page layout can be adapted for SVGs.

<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
    <style>
        circle {
            fill: #f1f3f4;
            stroke: #0e181b;
            stroke-width: 2;
        }
        @media (prefers-color-scheme: dark) {
            circle {
                fill: #0e181b;
                stroke: #f1f3f4;
            }
        }
    </style>
    <circle cx="50" cy="50" r="40" />
</svg>

Inline SVGs can also react to the CSS of the surrounding document, including its custom properties. That makes integrating SVGs into the page’s design a breeze since all colors can now come from a single source of truth in your CSS.

Setting colors for your browser

To integrate our color themes even more with the browser, we can provide some additional instructions.

Painting the browser

<meta
	name="theme-color"
	media="(prefers-color-scheme: light)"
	content="#f1f3f4"
/>
<meta
	name="theme-color"
	media="(prefers-color-scheme: dark)"
	content="#0e181b"
/>

The theme-color meta tag sets supported browsers style their own UI (most notably the head area with the URL bar) accordingly to our website. Not all browsers support that feature, but with Safari’s redesign in the coming version I think that’s going to change. Please note that this instruction does not take the color-scheme meta tag into consideration when we try to force a certain scheme, but always reacts to the device’s color scheme instead.

A screenshot of airhorner.com, a website featuring a large red circular graphic on a uniformly blue background. The screenshot is presented on the redesigned Safari Tech Preview and in an Android Smartphone. Both browsers feature a blue-tinted UI, seamlessly integrating the website into the browser.

When the browser UI color changes, so does the background color for our favicon. Since the favicon doesn’t accept media queries, our best choice is to use an SVG, as described above. Eric Bailey describes some best practices for favicons, including their theme-ability.

A screenshot of 'Scrpt', a web app. Two browser windows. The browser window in the back has dark mode activated. The browser UI is darkened and the white favicon is clearly visible against the dark grey background. The browser window in the front has light mode activated. The favicon is black and stands out against the white background.

Painting UI Elements

accent-color and ::selection are useful to style certain UI elements. Radio Buttons, Input Fields, and Range Sliders usually get a very distinct style directly from the browser (looking at you, Safari). Editing them isn’t strictly necessary for a dark theme, since the browser already sets sensible defaults for those colors, but they’re nice additions to colored themes.

input[type="checkbox"] {
    accent-color: var(--accent-color);
}

input::selection {
    background-color: var(--accent-color);
    color: var(--accent-contrast-color)
}

Please note that --accent-color is still very new and lacks browser support. Also, Safari doesn’t seem to have plans to implement it yet, while still providing quirky user agent styles that may stray far from our theme colors. It’s still important to normalize them.

Four screenshots of the same input elements in different themes. The input elements are a Textinput field with the value 'input', a checkbox labeled 'Checkbox', a radiobutton labeled 'radiobutton', and a button labeled 'button'. The top-left screenshot is themed dark blue accents with black text on a light grey background. The top-right one is light blue accents with white text on a dark grey background. The bottom-left one is bright yellow accents and white text on a red background. The bottom-right one is bright pink accents with white text on a dark purple background.

Wrapping up

I created a boilerplate that can be used to quickly create a themeable website. It’s not meant to be a complete solution, but it’s a good starting point for a custom theme. It covers the most important parts of a theme, like the color scheme, the favicon, and the UI elements. In addition to that, be sure to provide themed images and SVGs and watch color contrasts.

(Cover image: Kotagauni Srinivas, Unsplash, edited)