Nesting color schemes
Essay
When I wrote about the six (eight) levels of dark mode, I briefly mentioned that the CSS color-scheme
declaration can be set anywhere, not just on the root element. But until now I’ve never explained how this can be put to good use. Well, here I go.
Two ways come to mind, limiting a color scheme to a particular area, and proper nesting. But before we go there, do you agree that setting color-scheme
should always be done, even in the most mundane scenarios?
No nesting
If you are among those who say “so far I’ve never set color-scheme
, though I did create a light and dark scheme on several occasions”, then let me tell you the difference may be subtle, as described in “Don’t Forget the color-scheme Property” by Jim Nielson. TL;DR, always set color-scheme
or its corresponding HTML meta tag, even if your project only supports a single color scheme; it’ll neaten up things like form elements and scrollbars.
:root {
color-scheme: light dark;
}
/* Set both values if multiple color schemes are supported
(or just the one that matches the implemented scheme). */
Specifying the values light and dark on the :root element lets the rendering engine know both modes are supported by the document. This changes the default text and background colors of the page to match the current system appearance. Standard form controls, scroll bars, and other named system colors also change their look automatically.
We’ve established a baseline. Let’s continue this thought: Whenever you invert your color scheme on a page, you should adjust color-scheme
accordingly. But why would you even do that?
Simple nesting
We’ll proceed with the isolated case, the one where a specific area on a web page should be displayed in a specific color scheme, either always dark or light, or even the opposite of the user’s preference.
By the end of last year I found myself going down a Forced Colors Mode rabbit hole, and so I created—among other things—a test page for CSS System Colors, where I’m showcasing the system colors in both light and dark mode, side by side. To pull this off, the HTML contains the same color palette twice. If the overall page is shown in light mode, I’ll make sure that the palette used for comparison will be shown in dark mode, and vice versa.
@media (prefers-color-scheme: dark) {
.example.comparison {
color-scheme: light;
}
}
@media (prefers-color-scheme: light) {
.example.comparison {
color-scheme: dark;
}
}
Obviously it’s going to be even more straightforward if we decide upfront where to show a specific scheme.
.example.left {
color-scheme: dark;
}
.example.right {
color-scheme: light;
}
As interesting as this may be, it’s more on the niche side of things. Is there something more practical?
Advanced nesting
Let’s take a regular web page.
<!DOCTYPE html>
<html lang="en">
<head>…</head>
<body>
<header>BANNER</header>
<main>CONTENT</main>
<nav>NAVIGATION</nav>
<footer>FOOTER</footer>
</body>
</html>
Now imagine you want the site’s chrome (header, navigation and footer) to be always shown in dark color scheme, but the actual content should adhere to the user’s preference, i.e. light or dark.
There are several ways to achieve this.
Finicky
You could decide to control light and dark mode on the :root
level, and override all areas that aren’t <main>
with the dark scheme.
:root,
header, nav, footer {
background-color: var(--color-bg);
color: var(--color-text);
}
/* Provide colors for dark … */
:root {
--color-bg: #1a1a1a;
--color-text: white;
color-scheme: light dark;
}
/* … and light color scheme. */
@media (prefers-color-scheme: light) {
:root {
--color-bg: white;
--color-text: black;
}
}
/* Ignore preference on the site's chrome. */
header, nav, footer {
--color-bg: black;
--color-text: white;
color-scheme: dark;
}
I’m not too fond of this approach, and it has to do with overscroll-behavior
. When overscrolling is possible and active, the “wrong” background color will become visible. In other words, in light mode you’ll see a light background in the overscroll area, which feels out of place atop a dark header and below a dark footer.
Flipped
So let’s flip things around. We’ll start with a dark color scheme on :root
, and then release the tension on <main>
.
:root,
main {
background-color: var(--color-bg);
color: var(--color-text);
}
/* Enfore dark color scheme on the top level … */
:root {
--color-bg: black;
--color-text: white;
color-scheme: dark;
}
/* …, then open things up on the main area. */
main {
--color-bg: #1a1a1a;
}
@media (prefers-color-scheme: light) {
main {
--color-bg: white;
--color-text: black;
color-scheme: light;
}
}
That works, but it’s kinda verbose. And we are micromanaging color-scheme
instead of putting it in charge.
This we can easily fix. Instead of doing a color scheme override inside the prefers-color-scheme
media query, we can move the declaration to the initial <main>
block, where we set both values, not just light
.
But why stop there, how about we do this completely without media queries?
Polished
We need to call in a favor and ask our friend light-dark()
for help. This CSS color function became Baseline Newly available in May of 2024.
:root,
main {
background-color: var(--color-bg);
color: var(--color-text);
}
/* Provide all colors, but enforce dark scheme … */
:root {
--color-bg: light-dark(white, black);
--color-text: light-dark(black, white);
color-scheme: dark;
}
/* …, then adhere to preference. */
main {
--color-bg: light-dark(inherit, #1a1a1a);
color-scheme: light dark;
}
Looks neat, but there’s a catch: At the time of writing the light-dark()
function can only handle colors, so the moment you do something more complex you end up with media queries anyway.
Well, I wanted to end this article in a tidy state. Here’s hoping that you gained some insight on how to nest color schemes yourself. Nothing is off the table, you can pick and choose from all the variants I’ve shown here.
Let me know what you came up with.