This article, part of the writing collection, was published on and has ~2,250 words.
This article is also part of RSS Club, which means it appeared only in my RSS feeds for 3 days (released ), rewarding folks who subscribe to my RSS feeds with knowing about this post early!
Color Scheming
It’s been about a year and a half since I changed how colour schemes (i.e., light and dark themes) work on my website, and after reviewing and refactoring things again recently, I thought I would share how everything ties together and allows for different levels of control over colour schemes.
In this article, I’ll cover how to implement a colour-scheming system, gain some understanding of how to control the colour scheme in different ways, and write some CSS that leverages the strengths of the language to build a hierarchy of colour scheme controls.
Note: We’re going to build this hierarchy of controls out of order, but we’ll wrap everything back around to honour its order by the end.
Let’s build Permalink
4. OS/Browser-level Preference
To begin with, we should make sure to include this meta tag in the head of our page(s). This indicates a suggested [colour] scheme that user agents should use for a page
(MDN).
<meta name="color-scheme" content="light dark">Next, we need to indicate in our CSS what colour schemes we want to have available, and we can apply this to the document root (<html> in most cases) and the cascade will apply the appropriate colour scheme to the entire document:
:root {
color-scheme: light dark;
}This, alone, allows us to write CSS that responds dynamically to the OS/Browser-level Preference for a light or dark colour scheme. Where colours are concerned in our CSS, we can use the light-dark() function to hook into which of the two colour schemes is active and provide an appropriate colour for both conditions.
Also, because we’ve written light dark, we have indicated that where an OS/Browser-level Preference does not exist, we should default to the first value. This means that in the absense of all preferences and overrides, the default colour scheme will be the light one. If we had instead written dark light, the dark colour scheme would be the default.
1 & 2. Element-level & Page-level Overrides
Next, let’s take care of Page-level and Element-level colour scheme overrides. The way I’ve chosen to do this is by creating classes for each colour scheme that can be appled to any element and use a specific colour scheme for itself and its children:
.light {
color-scheme: light;
}
.dark {
color-scheme: dark;
}We can apply these classes to the html element to force an entire page to use a specific colour scheme:
<html class="dark">Similarly, we might want a particular section of a page to always use a specific colour scheme, so we can apply the class to a wrapper element and allow the color-scheme value to cascade to its children:
<main class="light">
...
</main>3. Site-level Preference
The last part is unfortunately the most complex, so let’s dive in and flesh out how we can allow website visitors to override their OS/Browser-level Preference without stepping on top of our Page-level/Element-level Overrides.
To start with, I’m using David Darnes’ web component, <storage-form>, to capture the Site-level Preference. This works by wrapping a <form> that contains a <select>. The <storage-form> web component listens for selection changes on the <select> value and saves those changes to local storage.
<storage-form>
<form autocomplete="off">
<select name="color-scheme">
<optgroup label="Select a colour scheme">
<option value="" selected>OS Default</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</optgroup>
</select>
</form>
</storage-form>From then on, whenever the web component’s JavaScript finds the same <select> (matched by name attribute) on subsequent page loads or on other pages, it will modify the selected <option> in the DOM to match the local storage value.
This takes care of persistence of Site-level Preferences. I cannot overstate how amazingly powerful this little web component is! I use it every time I need to manage some sort of state
on my website.
What’s great about this approach is that we can leave <storage-form> alone from here and continue using CSS to control and manage how we respond to different colour schemes. We can build selectors that match when a particular <option> is selected (i.e., checked), and using the :has() selector, we can then apply a specific colour scheme based on that selection:
:root:has([name="color-scheme"] [value="light"]:checked) {
color-scheme: light;
}
:root:has([name="color-scheme"] [value="dark"]:checked) {
color-scheme: dark;
}So when the light option has been selected, we are applying the light colour scheme to the document root, and vice versa for the dark option.
A nod to performance
Before we can put everything together, there are some problems we need to address.
Firstly, we need to think about how we initiate the JavaScript that defines the <storage-form> web component because until it is loaded, the Site-level Preference that exists in local storage won’t have been mirrored onto the <select> on the page. At present, we’re relying on the value of the <select> to apply a Site-level Preference.
This gives us some options and a choice to make.
We could instantiate the web component in the head of our page(s) and be sure that by the time the HTML parser reaches our <storage-form> and <select>, the browser will know what to do with it, and the correct colour scheme will be applied; however, this will block the parsing of HTML while the entirety of the web component’s JavaScript is being processed.
We might think to defer the script or run it asynchronously, but no matter how we slice it—loading the JavaScript before the HTML or vice versa—we’re going to run into an unsightly problem. The effect will be more pronounced on slower devices and/or slower connections, but there will be a period of time where some portion of HTML has been parsed and rendered before the locally-stored colour scheme value has been applied to the HTML. Depending on how different in brightness the light and dark colour schemes are, this can result in an intense shift from light to dark or dark to light before and after the locally-stored colour scheme value is applied.
What I’ve done to solve this is to defer the loading of the JavaScript that initiates the web component and instead include a small snippet of (minified) JavaScript in the <head> that is technically parser-blocking, but will reliably run (or fail) very quickly.
const COLOR_SCHEME = localStorage.getItem("color-scheme");
if (COLOR_SCHEME) {
document.documentElement.setAttribute("data-color-scheme", COLOR_SCHEME);
}
window.addEventListener("load", () => {
document.documentElement.removeAttribute("data-color-scheme");
});What this does is independently check the browser’s local storage for the same value that gets set by the <select> in the <storage-form> web component. If a value is found, then it sets a data attribute on the document root. When the window has finished loading, this means that the <storage-form> JavaScript has completed, so the data attribute on the document root gets removed, allowing the CSS selector based on the <select> value (which is now in parity with local storage) to once again take control.
We can apply some further CSS that will apply the correct colour scheme during this in-between
state when the data attribute is present on the document root:
[data-color-scheme="light"] {
color-scheme: light;
}
[data-color-scheme="dark"] {
color-scheme: dark;
}Review and Refactor
Let’s put all of our CSS together. We’ll combine our initial :root declaration alongside two other declarations, one for each of two groups, selectors that should apply the light colour scheme and selectors that should apply the dark colour scheme.
Warning! The CSS below is not the full solution! Keep reading below to learn about what we need to do to fix its problems.
:root {
color-scheme: light dark;
}
:root:has([name="color-scheme"] [value="light"]:checked),
[data-color-scheme="light"],
.light {
color-scheme: light;
}
:root:has([name="color-scheme"] [value="dark"]:checked),
[data-color-scheme="dark"],
.dark {
color-scheme: dark;
}Unfortunately, while this CSS does target the different parts of the DOM that we need, there are a few last considerations to make before this will preserve the order of preferences and overrides that we want. We’re going to use two important features of CSS to help us tailor things to do that.
First of all, we can amend our first declaration with the :where() selector. This selector nullifies the specifity of all selectors inside it, so when our :root selector becomes :where(:root), it has a specificity of (0,0,0). This makes it very easy to out-specify later on.
:where(:root) {
color-scheme: light dark;
}Next, we’ll consider the three methods of control (<select> value, data attribute, class) and which should be more specific.
The <select> value selector and data attribute selector can be treated as one—that is to say that their specificity in this hierarchy should be identical, as they both actually represent the same thing, the value of the <select>.
Based on the hierarchy that we want, the specificity of the class-based controls should be higher than the <select> value and data attribute controls.
Let’s consider the specificity of the three selectors in each group:
:root:has([name=color-scheme] [value=light]:checked) |
(0,4,0) |
|---|---|
[data-color-scheme=light] |
(0,1,0) |
.light |
(0,1,0) |
Given that their specificities don’t align with what we want, we can leverage a second important feature of CSS, source order.
Let’s use the :where() selector again to nullify the specificity of the <select> value selector and data attribute selector:
:where(:root:has([name="color-scheme"] [value="light"]:checked)),
:where([data-color-scheme="light"]),
.light {
color-scheme: light;
}
:where(:root:has([name="color-scheme"] [value="dark"]:checked)),
:where([data-color-scheme="dark"]),
.dark {
color-scheme: dark;
}But why do this? Won’t they have the same specificity as the :where(:root) declaration?
They do have the same specificity (0,0,0), but, critically, they come after the :where(:root) declaration in source order. This means that our new <select> value and data attribute selectors take precedence over :where(:root).
I’ve inlined the specificity of each selector to demonstrate this below:
/**
* (0,0,0) — 1st in source order
*/
:where(:root) {
color-scheme: light dark;
}
/**
* Order of selector specificities below:
* (0,0,0) – 2nd in source order
* (0,0,0) – 2nd in source order
* (0,1,0)
*/
:where(:root:has([name="color-scheme"] [value="light"]:checked)),
:where([data-color-scheme="light"]),
.light {
color-scheme: light;
}
:where(:root:has([name="color-scheme"] [value="dark"]:checked)),
:where([data-color-scheme="dark"]),
.dark {
color-scheme: dark;
}Fin. Permalink
At this point, the build is complete! For the most part, the code involved in this solution rarely needs amending or changing, so I tend to put it wherever you might find things like resets in my CSS.
We can make powerful use of the light-dark() function to toggle between colour values depending on the active colour scheme, e.g.:
main {
background-color: light-dark(#e1e1e1, #1e1e1e);
color: light-dark(#1e1e1e, #e1e1e1);
}Furthermore, because we’ve set up a hierarchy for the controls that we’ve made available to us, we’ve instructed our styles to hook into visitors’ OS/Browser-level Preference. If they’d rather not let their OS/Browser dictate their preference, they can use the <select> to choose a Site-level Preference that will persist across tabs, windows, and even sessions, thanks to the power of local storage.
We’ve also thought about user experience and poor-performance situations by introducing a light sprinkling of JavaScript. Despite incurring a small performance impact itself, this gives us the tremendous benefit of applying a chosen colour scheme to the page as early as possible to prevent the wrong colour scheme from being shown before the heftier parts of this solution have had a chance to make their impact.
On top of that, we, as website authors, can override the user’s Site-level Preference to be able to say that certain pages or parts of pages should always be rendered with a specific colour scheme.
Full CSS Solution
:where(:root) {
color-scheme: light dark;
}
:where(:root:has([name="color-scheme"] [value="light"]:checked)),
:where([data-color-scheme="light"]),
.light {
color-scheme: light;
}
:where(:root:has([name="color-scheme"] [value="dark"]:checked)),
:where([data-color-scheme="dark"]),
.dark {
color-scheme: dark;
}Bonus!
Although it isn’t stable across browsers yet, the if() selector will allow us to toggle non-colour property values based on the resolved colour scheme. This is great for things like changing a background-image based on the colour scheme. That would look something like this:
background-image: if(color-scheme(light): url("/light.png"); else: url("/dark.png"));Fin. Part 2: Electric Boogaloo Permalink
We can actually take this one step further without much extra CSS, and this gives us the ability to set a Page-level or Element-level colour scheme that is more of a suggestion than a rule, and can be overridden by a Site-level Preference.
This gives us two new ways to apply colour schemes to our pages and an amended hierarchy of how colour schemes can be applied:
- Element-level Override
- Page-level Override
- Site-level Preference
- Element-level Suggestion
- Page-level Suggestion
- OS/Browser-level Preference
:where(:root) {
color-scheme: light dark;
}
:where(:root:has([name="color-scheme"] [value=""]:checked):not([data-color-scheme], .light, .dark)) {
&:where(.suggested-light),
& :where(.suggested-light) {
color-scheme: light;
}
&:where(.suggested-dark),
& :where(.suggested-dark) {
color-scheme: dark;
}
}
:where(:root:has([name="color-scheme"] [value="light"]:checked)),
:where([data-color-scheme="light"]),
.light {
color-scheme: light;
}
:where(:root:has([name="color-scheme"] [value="dark"]:checked)),
:where([data-color-scheme="dark"]),
.dark {
color-scheme: dark;
}This introduces a rather unwieldy selector, :where(:root:has([name="color-scheme"] [value=""]:checked):not([data-color-scheme], .light, .dark)), so let’s break down what it does:
:root:has([name="color-scheme"] [value=""]:checked)checks if the<select>on the page has its default value but targets the document root when applying styles. When this is the case, it means that the user has selected neitherlightnordarkas their Site-level Preference.:not([data-color-scheme], .light, .dark)ensures that the document root has neither adata-color-schemeattribute nor the classeslightordarkapplied to it.:where(...)makes sure that this complex selector has a specificity of(0,0,0). This ensures that it doesn’t compete in specificity with other important parts of this solution, but rather takes a specific place in the source order to help define its place in the hierarchy (see below).
The two extra classes that this introduces, .suggested-light and .suggested-dark, can be applied to our HTML in the same way as our previous classes (.light and .dark), but the important thing to note here again is the source order.
By placing these declarations between the two parts of our previous solution (and matching the specificity of (0,0,0)), they receive higher precendence than the OS/Browser-level Preference but lower precedence than the Site-level Preference.
This can be useful in situations where, for artistic reasons, you'd like a particular page or section to be a specific colour scheme, but if the user has set a Site-level Preference—maybe for reasons relating to eyesight or otherwise—then that choice should be respected and the particular page/section should be rendered as they’ve chosen, not from your suggested
colour scheme.
Try it out Permalink
Jump to the top and open the settings modal to toggle the colour scheme. (See: Light/Dark
).
light or dark, based on the hierarchy of controls.light colour scheme.dark colour scheme.light colour scheme as a suggestion, but if the user has selected a Site-level Preference or a Page-level Override exists, that will be used instead.dark colour scheme as a suggestion, but if the user has selected a Site-level Preference or a Page-level Override exists, that will be used instead.