The other day, Florens Verschelde asked about defining dark mode styles for both a class and a media query, without repeat CSS custom properties declarations. I had run into this issue in the past but hadn’t come up with a proper solution.
What we want is to avoid redefining—and thus repeating—custom properties when switching between light and dark modes. That’s the goal of DRY (Don’t Repeat Yourself) programming, but the typical pattern for switching themes is usually something like this:
:root {
--background: #fff;
--text-color: #0f1031;
/* etc. */
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0f1031;
--text-color: #fff;
/* etc. */
}
}
See what I mean? Sure, it might not seem like a big deal in an abbreviated example like this, but imagine juggling dozens of custom properties at a time—that’s a lot of duplication!
Then I remembered Lea Verou’s trick using --var: ;
, and while it didn’t hit me at first, I found a way to make it work: not with var(--light-value, var(--dark-value))
or some nested combination like that, but by using both side by side!
Certainly, someone smarter must have discovered this before me, but I haven‘t heard of leveraging (or rather abusing) CSS custom properties to achieve this. Without further ado, here’s the idea:
--color: var(--light, orchid) var(--dark, rebeccapurple);
If the --light
value is set to initial
, the fallback will be used (orchid
), which means --dark
should be set to a whitespace character (which is a valid value), making the final computed value look like this:
--color: orchid ; /* Note the additional whitespace */
Conversely, if --light
is set to a whitespace and --dark
to initial
, we end up with a computed value of:
--color: rebeccapurple; /* Again, note the whitespace */
Now, this is great but we do need to define the --light
and --dark
custom properties, based on the context. The user can have a system preference in place (either light or dark), or can have toggled the website‘s theme with some UI element. Just like Florens‘s example, we’ll define these three cases, with some minor readability enhancement that Lea proposed using “on” and “off” constants to make it easier to understand at a glance:
:root {
/* Thanks Lea Verou! */
--ON: initial;
--OFF: ;
}
/* Light theme is on by default */
.theme-default,
.theme-light {
--light: var(--ON);
--dark: var(--OFF);
}
/* Dark theme is off by default */
.theme-dark {
--light: var(--OFF);
--dark: var(--ON);
}
/* If user prefers dark, then that's what they'll get */
@media (prefers-color-scheme: dark) {
.theme-default {
--light: var(--OFF);
--dark: var(--ON);
}
}
We can then set up all of our theme variables in a single declaration, without repetition. In this example, the theme-*
classes are set to the html
element, so we can use :root
as a selector, as many people like to do, but you could set them on the body
, if the cascading nature of the custom properties makes more sense that way:
:root {
--text: var(--light, black) var(--dark, white);
--bg: var(--light, orchid) var(--dark, rebeccapurple);
}
And to use them, we use var()
with built-in fallbacks, because we like being careful:
body {
color: var(--text, navy);
background-color: var(--bg, lightgray);
}
Hopefully you’re already starting to see the benefit here. Instead of defining and switching armloads of custom properties, we’re dealing with two and setting all the others just once on :root
. That’s a huge improvement from where we started.
Even DRYer with pre-processors
If you were to show me this following line of code out of context, I’d certainly be confused because a color is a single value, not two!
--text: var(--light, black) var(--dark, white);
That’s why I prefer to abstract things a bit. We can set up a function with our favorite pre-processor, which is Sass in my case. If we keep our code above defining our --light
and --dark
values in various contexts, we need to make a change only on the actual custom property declaration. Let’s create a light-dark
function that returns the CSS syntax for us:
@function light-dark($light, $dark) {
@return var(--light, #{ $light }) var(--dark, #{ $dark });
}
And we’d use it like this:
:root {
--text: #{ light-dark(black, white) };
--bg: #{ light-dark(orchid, rebeccapurple) };
--accent: #{ light-dark(#6d386b, #b399cc) };
}
You’ll notice there are interpolation delimiters #{ … }
around the function call. Without these, Sass would output the code as is (like a vanilla CSS function). You can play around with various implementations of this but the syntax complexity is up to your tastes.
How’s that for a much DRYer codebase?
More than one theme? No problem!
You could potentially do this with more than two modes. The more themes you add, the more complex it becomes to manage, but the point is that it is possible! We add another theme set of ON
or OFF
variables, and set an extra variable in the list of values.
.theme-pride {
--light: var(--OFF);
--dark: var(--OFF);
--pride: var(--ON);
}
:root {
--text:
var(--light, black)
var(--dark, white)
var(--pride, #ff8c00)
; /* Line breaks are absolutely valid */
/* Other variables to declare… */
}
Is this hacky? Yes, it absolutely is. Is this a great use case for potential, not-yet-existing CSS booleans? Well, that’s the dream.
How about you? Is this something you’ve figured out with a different approach? Share it in the comments!
Hey great hacks! I love it.
Wonder if there is a sass function to do this for you? Seems possible. Thank you for posting!
Sure thing! You can use a pre-processor to handle the messy syntax for you, see this section of this article. Or push the automation further with some theme tokens: https://codepen.io/chriskirknielsen/pen/QWGaXqP
This feels like an anti pattern to me. If you want to add additional themes, you always have to modify the base definitions and mix in the new theme. I prefer the approach, where a theme just overrides the properties it needs to override. This way, you can have each theme in a separate file if you use a preprocessor. Adding or removing a theme is just one import and not going through all properties one by one.
This is definitely a hack and not some best practice. It’s more in the realm of the scientists of Jurassic Park that thought about what they could do, not what they should do. ;)
If you do need to change the themes frequently, and really want to use this method, then it might be better to abstract this up a notch in a pre-processor with a more complex function that reads a map of tokens for each theme. Check out this demo for a rough idea of how to do it: https://codepen.io/chriskirknielsen/pen/QWGaXqP?editors=0100 Not saying it’s the best way, just that it can be done. :)
Interesting! How often would you use this, or another similar solution? I’m asking this from the perspective of someone more proficient in the backend with a good amount of React experience – I’d likely just have two separate sets of stylesheets and do a dynamic import. Is that just unsustainable after a certain point?
I think this is a cool trick to know but I honestly can’t speak to the application of it in production — it’s more of an experiment.
You definitely could have a global stylesheet that reads your
--text
,--bg
, etc., alongside individual stylesheets to declare the variables, and then swap them out on the fly. That works as long as you keep the old theme file in place while the new file loads, to avoid a page where all the variables are missing.If you’re already running React anyways, I think this trick here might not be super useful. I also am not so well-versed in React-land so there might be some cool tricks to achieve a similar result already, but this is a CSS-only approach (if you don’t count the
theme-*
class switching) that I thought would be fun to talk about. :)Except now you have to repeat yourself with every declaration, rather than twice at the top of the file
Unless I misunderstood your point, that’s not the case: once you have declared your variables like
--text
or--accent
in:root
, it’s available in your entire stylesheet, nothing to re-declare.Using the animation frame tricks custom properties from a previous post (https://css-tricks.com/css-switch-case-conditions/) I found you could do this even DRYer:
That’s another way to do it; whichever floats your boat! It does require a tiny bit more thinking with the percentages if you have an odd/large number of themes, but for most cases with one light, one dark, your way works well!
I remember using a kind of similar trick some years ago to get responsive font sizing but it needed JS to adjust the negative delay based on viewport size. With custom properties, things are a lot easier!
I realized you didn’t need to use up the entire animation, so you could just map each keyframe to 1% and set the duration to 100s, which gives you more than enough room for most applications. My main concern with going all in on this animation keyframe method is if there is some significant performance cost or accessibility issue with it. I don’t know enough about the internals of animation implementations to know if this would incur a massive memory cost, or could be messed up by user settings.
I’ve written this a few months back. https://dev.to/drno/theming-and-coloring-finally-made-efficient-in-css-thanks-to-an-oop-inspired-pattern-27ca Been using a more advanced version of this pattern in prod for more than a year. Would like to know what you think of it
PS : V2 coming in a few days