Nested Dark Mode Via CSS Proximity
Note: This might be more accurately titled “Nested Dark Mode via CSS Inheritance” but I’m going with ‘proximity’ since that term helped me to grok this concept better. “Proximity” isn’t a formally defined term in any CSS spec, as far as I can tell. However, there is a related term called “scope proximity” being defined for @scope
.
Nested theming
Let’s start by defining some terms. For the purposes of this article I’m considering Light/Dark Mode to be a theme. Nested theming is the case where the page is styled in one theme but sub-sections of it use another theme. A practical example of nested theming could be a scrolling product page that has discrete sections, some on a light background and some on a dark background, or maybe a rich editor web app that allows users to preview content in different themes.
CSS selectors don’t consider proximity
Let’s look at a basic example of nested theming:
<div data-theme="blue">
<a>Should be blue</a>
<div data-theme="red">
<a>Should be red (but is actually blue)</a>
</div>
</div>
[data-theme="red"] a {
color: red;
}
[data-theme="blue"] a {
color: blue;
}
In the example above, the blue theme link style incorrectly applies within the red theme. This is because CSS is comparing the two selectors in isolation and in isolation both selectors have identical specificity, so last in wins. It is not considering where the component parts of these selectors are in DOM relative to the element targeted by the selector.
One fix would be to duplicate the styles for the nested theme but this adds significant complexity because it requires describing every combination of nested themes in advance.
/* Brute force solution that doesn’t scale */
[data-theme="red"] a,
[data-theme="blue"] [data-theme="red"] a {
color: red;
}
To find a better solution we need to revisit some CSS fundamentals.
Inheritance
Some CSS properties inherit by default and some don’t.
color
is an example of a property that inherits. If the color
property is set on <body>
, all elements within that one will get that color value by default because all elements by default inherit their value for the color
property from their parent element.
background
is an example of a property that does not inherit. If the background
property is set on <body>
that value will only apply to the <body>
element, not its descendants.
Proximity is a feature of inheritance
This behavior of inheriting the closest set value from ancestors means that the proximity of a given element to an ancestor with a set value determines the resulting value. If two or more ancestor elements have set values, the child element will always use the value from its closest parent. This is exactly the behavior we want for nesting themes. We only want the values from the closest theme container.
With that in mind, there is a much simpler way to fix the example from above:
[data-theme="red"] {
color: red;
}
[data-theme="blue"] {
color: blue;
}
<div data-theme="blue">
<a>Should be blue</a>
<div data-theme="red">
<a>Should be red (and now actually is)</a>
</div>
</div>
Did you spot the difference?
The selectors still have identical specificity but no longer style individual elements within their respective theme contexts. Now anchor tags are inheriting the color style instead of getting it set directly on them. This is the magic concept: CSS inheritance is based on proximity.
Custom properties always inherit by default
Where this gets especially cool with regards to nested theming is that custom property values always inherit by default and they can be set as values on CSS properties that normally do not inherit. In other words, via custom properties, we can make nearly all of CSS proximity-dependent.
Using proximity for nested dark mode
Here is an example that puts all of this together:
<main data-theme="light">
<article data-theme="dark">
<figure data-theme="light">…</figure>
</article>
</main>
[data-theme="light"] {
--color-backgroundPrimary: white;
--color-backgroundSecondary: ghostwhite;
}
[data-theme="dark"] {
--color-backgroundPrimary: black;
--color-backgroundSecondary: gray;
}
/* Now uses of the custom properties defined above will always
be correct for the given light/dark context set in DOM */
article {
background: var(--color-backgroundPrimary);
}
figure {
background: var(--color-backgroundSecondary);
}
Behold the magic of CSS proximity. Leveraging proximity via inheritance makes it simple to write styles for any number of theme contexts and nest them arbitrarily to any desired depth.
[data-theme="red"] {
--color-primaryText: red;
}
[data-theme="blue"] {
--color-primaryText: blue;
}
p {
color: var(--color-primaryText);
}
<div data-theme="blue">
<p>Blue</p>
<div data-theme="red">
<p>Red</p>
<div data-theme="blue">
<p>Blue</p>
<div data-theme="red">
<p>Red</p>
<div data-theme="blue">
<p>Blue</p>
</div>
</div>
</div>
</div>
</div>
The inherit keyword
At this point, you might be thinking: What about the inherit
keyword? One of the major issues with using something other than a custom property for nested theming is it requires every ancestor element between theme container and target element to have the same value for that property. In many practical applications of nested theming, this is a poor solution because there will often be many elements between the theme container and target element and many of those elements should not have a value for that style. Custom properties are a way around this because, when they inherit, they have no effect on element styling until they are invoked as a property value.
Additionally, for a property like background
which does not inherit by default, the inherit
keyword would need to be set on that property for every element all the way down the chain; further increasing the code complexity.
Further reading
Miriam Suzanne has written articles about the topics described above going back to at least 2019. Her article about custom property “stacks” takes these concepts much further and explores some mind-bendingly clever ways to mix uses of proximity with uses of the cascade. I recommend also reading her fascinating thinking around formalizing concepts of scoping and proximity for her work on CSS Cascading and Inheritance Level 6.