I have:
body { background: white; }
To display dark mode, I use .dark
class:
.dark body { background: black; }
And to detect if user has their OS set to use dark theme, we have prefers-color-scheme:
@media (prefers-color-scheme: dark) {
body { background: black; }
}
And then we have the idea of DRY (Don’t Repeat Yourself) programming. Can we define dark mode without repeating CSS properties declarations, and in the process, allow users to switch between the color modes via JS?
With the above example, the .dark
class and the media query are copies of each other.
Skipped prefers-color-scheme
in CSS and used:
body { background: white; }
.dark body { background: black; }
Then via JS, detect their settings and adjust the
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.getElementsByTagName('html')[0].classList.add('dark');
}
The problem with this approach is it doesn't use prefers-color-scheme
in CSS.
While I can add:
@media (prefers-color-scheme: dark) {
body { background: black; }
}
It won't let me toggle the color schemes via JS because I can't cancel prefers-color-scheme: dark
for a user who has dark set in their OS preferences.
What is the 2022 way of solving this?
Let's analyze it.
We definitely cannot put the dark mode styles only inside the media query, since there will be no way to apply it via in-site choice. So we want the styles to be under a certain class (dark
) with no media query (so that the class can be toggled via JS), but the media query should somehow pull the same styles even without that class, and without repeating the styles. We probably also want a light
class to override the media query if the user chooses to.
The perfect solution here would be the abandoned @apply at-rule which could allow reusing of a bunch of styles under a single variable. But this was, well, abandoned.
The standard proposal for reusing a bunch of styles is using mixins on a CSS preprocessor (so it's not repeated on the project's source code but it's repeated on the compiled CSS). If all properties are applied to the <body>
element or a few elements then I believe that's the way to go. However you asked for a solution without preprocessors.
The other way would be to use the media query in the JS rather than in the CSS itself, and toggle the class accordingly. This method would handle even complicated themes with properties applied to many elements, but it may have a "Flash of unstyled content" until the JS takes effect. Anyway you already brought that up and asked for a solution that keeps the media query in the CSS itself.
You also asked to not use repeated CSS variables. Now, I'm not sure it would be possible without repeating anything, but pheraps we can reduce the repeatation to two "toggle" variables, regardless of the number of affected properties.
var root = document.documentElement,
theme = window.getComputedStyle(root)
.getPropertyValue('--light') === ' ' ? 'dark' : 'light';
document.getElementById('toggle-theme')
.addEventListener('click', toggleTheme);
function toggleTheme() {
root.classList.remove(theme);
theme = (theme === 'dark') ? 'light' : 'dark';
root.classList.add(theme);
}
/*
"initial"ized variables are like undefined variables and will resolve to
their fallback value.
Whitespaced variables (the space is required) will serve as an empty value
in chaining.
*/
@media (prefers-color-scheme: dark) {
:root {
--light: ;
--dark: initial;
}
}
@media (prefers-color-scheme: light) {
:root {
--dark: ;
--light: initial;
}
}
:root.dark {
--light: ;
--dark: initial;
}
:root.light {
--dark: ;
--light: initial;
}
#content {
background-color: var(--dark, darkblue) var(--light, lightblue);
color: var(--dark, white) var(--light, black);
border: 5px solid var(--dark, blue) var(--light, yellow);
}
<div id="content">
Hello, world!
<button id="toggle-theme">Toggle theme</button>
</div>
Finally, I can think of another possible hack, though I really don't like it. The idea is to add two <div>
s with the only purpose of inheriting styling one to another based on preferred/selected theme.
var root = document.documentElement,
theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.getElementById('toggle-theme')
.addEventListener('click', toggleTheme);
function toggleTheme() {
root.classList.remove(theme);
theme = (theme === 'dark') ? 'light' : 'dark';
root.classList.add(theme);
}
.dark-styles {
background: darkblue;
color: white;
border-color: blue;
}
.light-styles {
background: lightblue;
color: black;
border-color: yellow;
}
:root.dark .light-styles {
all: inherit;
}
@media (prefers-color-scheme:dark) {
:root:not(.light) .light-styles {
all: inherit;
}
}
#content {
border: 5px solid;
border-color: inherit;
}
<div class="dark-styles">
<div class="light-styles">
<div id="content">
Hello, world!
<button id="toggle-theme">Toggle theme</button>
</div>
</div>
</div>