I'm finally using CSS variables
Published almost 2 years ago
Every time I see someone use CSS variables, I feel a wave of FOMO.
Then I remember that the most common way people use CSS variables—to store a design token in one place instead of repeating it across all CSS files—is completely unnecessary in my projects.
I use Tailwind, so my design tokens are stored in a JS file. In some cases, I even generate the tokens and token names programmatically.
On top of that, I'm addicted to find-and-replace, so even if I were repeating a design token across an entire project, I'd have no trouble replacing it, except in the edgiest of edge cases.
But recently, I found two killer use cases for CSS variables in my Tailwind projects: container style queries and space toggles.
The thing these two use cases have in common is that they don't use CSS variables to store design tokens. Instead, they store booleans, and the effects end up being pretty wild.
Let's look at some cool shit!
Container style queries
All major browsers support container queries, so a lot of people are already aware of them, and using them via the official Tailwind plugin.
A lot fewer people are aware of container style queries, which let you query any CSS property on the container element, not just it's width. This includes CSS variables.
My favorite breakdown of this feature is Una Kravets' article.
As of writing this article, you can actually only query CSS variable values, and you can only do it in Chrome and Edge, so it'll be a while until this feature is useful in production.
But when that happens, we'll be able to store any arbitrary value in a CSS variable, then read that value from any descendant element.
Applying themes is IMHO the best use case for this feature:
/* The Tailwind class for identifying a container element */
.\@container {
container-type: inline-size;
}
.theme-1 {
--theme-1: true;
--theme-2: false;
--theme-3: false;
}
.theme-2 {
--theme-1: false;
--theme-2: true;
--theme-3: false;
}
.theme-3 {
--theme-1: false;
--theme-2: false;
--theme-3: true;
}
/*
We can read the theme state from our container style query,
and apply any styles as needed.
*/
@container style(--theme-1: true) {
/*
This selector has 1 specificity, and can be easily
overridden by selectors outside this query, further
down the cascade.
*/
h1 {
color: red;
}
}
@container style(--theme-2: true) {
h1 {
color: yellow;
}
}
@container style(--theme-3: true) {
h1 {
color: blue;
}
}
Taking this a step further, we can re-implement the prose
theme of the Tailwind typography plugin, and we can easily implement its .not-prose
feature for disabling the prose theme.
If you haven't seen that before, the basic idea is:
- Add
.prose
to an element to apply typography styles to all its descendants. - Add
.not-prose
to any descendant of the.prose
element to remove those typography styles from all of its descendants.
Tailwind accomplishes this by recursing through a tree of arbitrarily nested CSS selectors and wrapping every individual selector with .prose :where(.some-selector):not(:where([class~="not-prose"] *))
. That beast of a selector ensures that prose styles only apply to descendants of .prose
, but not descendants of .not-prose
.
It also comes with this important caveat:
Note that you can’t nest new
.prose
instances within a.not-prose
block at this time.
This caveat is a limitation of using :where()
and CSS nesting to support theming, but spoiler alert: we can solve it with container style queries.
Okay, to help us re-implement, let's boil that all down to a list of requirements:
- Adding
.prose
to an element should apply typography styles to all its descendants. - Adding
.not-prose
to any descendant of the.prose
element should remove those typography styles from all of its descendants. - It would be nice if you could nest new
.prose
instances within a.not-prose
block.
Here's how we can accomplish that with container style queries:
.prose {
container-type: inline-size;
container-name: prose;
--prose: true;
}
.not-prose {
container-type: inline-size;
container-name: prose;
--prose: false;
}
/*
This container style query asserts that styles will only
be applied when the nearest ancestor container named `prose`
sets the value of the `--prose` variable to `true`.
*/
@container prose style(--prose: true) {
h1 {
color: blue;
}
}
And here's a Tailwind playground where you can see it in action!
With that code, we've solved requirements 1, 2, and most importantly 3, which is not possible with the current version of the Tailwind typography plugin 🎯
It's important to note that this doesn't make Tailwind typography obsolete. Tailwind's recursion through the selector tree doesn't just implement the .not-prose
feature—it also forces all nested selectors down to 1 specificity, so that we can override styles with Tailwind utility classes, and without using !important
.
There's currently no CSS-only solution to that problem. Tailwind typography will still be a go-to plugin, but container style queries will simplify the internal implementation, and make the generated CSS a bit smaller.
Container style queries will also shake things up in Baleada Ancestor Variants, a custom Tailwind plugin I wrote to make custom themes easier to work with.
In that plugin, I use :is()
and :where()
pseudo-selectors pretty heavily, similar to what Tailwind does with .prose
. But container style queries are a much easier, more flexible, and more readable solution 👍
Container style queries are 🔥🔥🔥
Space toggles
Space toggles are a ridiculously powerful happy accident of the CSS spec.
I learned about them from a CSS Tricks article, but I thought it was hard to understand there, so I'll take a stab at explaining it's awesomeness.
Core concepts
--foo: ;
is a valid CSS declaration, i.e. you can assign whitespace as a value.- When
--foo
is a whitespace,color: var(--foo) blue;
evaluates tocolor: blue;
in the browser, trimming the whitespace character and leaving you with a validcolor
value. - When
--foo
is something else, e.g.--foo: initial;
, thencolor: var(--foo) blue;
evaluates tocolor: initial blue;
, which is invalid CSS and gets ignored by the browser at runtime.
With that in mind, we could do something like this:
<style>
.some-parent {
--foo: ; // Whitespace means TRUE
}
.some-other-parent {
--foo: initial; // `initial` means FALSE
}
.some-child {
color: var(--foo) blue;
}
</style>
<div class="some-parent">
<!--
This `.some-child` element inherits the `--foo` value from
`.some-parent`. The `color` rule will evaluate to `color: blue`.
This is valid CSS, so this `.some-child` element will have a
blue text color.
-->
<div class="some-child"></div>
</div>
<div class="some-other-parent">
<!--
This `.some-child` element inherits the `--foo` value from
`.some-other-parent`. The `color` rule will evaluate to
`color: initial blue`.
This is invalid, so browsers will ignore it, and this
`.some-child` element will default to inheriting `color`
from the nearest ancestor with a `color` setting.
-->
<div class="some-child"></div>
</div>
<div class="some-parent">
<div class="some-other-parent">
<!--
In this example, `.some-parent` sets `--foo`, but then
`.some-other-parent` overrides it.
Since this `.some-child`'s closest ancestor with a `--foo`
declaration sets `--foo` to `initial`, The `color` rule will
evaluate to `color: initial blue`.
Just like before, this is invalid CSS. Browsers will ignore
it, and this `.some-child` element will not have a blue color.
-->
<div class="some-child"></div>
</div>
</div>
This is a simpler and less useful example of the feature, but even it has its advantages. Let's compare and contrast:
/* Without space toggle */
.some-parent .some-child { color: blue; }
.some-other-parent .some-child { color: unset; }
Note how both selectors have a specificity of 2, making them harder to override.
Also, if we wanted to structure our markup as .some-other-parent > .some-parent > .some-child
, the child element would end up with unset
as the color value, because even though .some-parent
is closer in the DOM tree, the .some-other-parent .some-child
rule is further down the CSS cascade.
Contrast that with the space toggle example:
/* With space toggle */
.some-parent { --foo: ; }
.some-other-parent { --foo: initial; }
.some-child { color: var(--foo) blue; }
Now, the .some-child
selector has a specificity of 1, making it easier to override.
Also, if we wanted to structure our markup as .some-other-parent > .some-parent > .some-child
, the child element would end up with blue
as the color value, because .some-parent
is a closer ancestor in the DOM tree, so it's able to override the --foo: initial;
declaration.
This might all seem nitpicky, but when you do cooler things with space toggles, it has important implications.
Cooler real-world example
Let's look at a simplified implementation of the .center-all-x
class from my Baleada Utilities package.
The requirements for this Tailwind utility class are:
.center-all-x
must center items horizontally, regardless of flex direction. So, if I add it to an element withflex-direction: row
,.center-all-x
must setjustify-content: center
. If I add it to an element withflex-direction: column
,.center-all-x
must setalign-items: center
..center-all-x
must have a specificity of 1..center-all-x
must dynamically update its behavior based on the element's computedflex-direction
value at runtime. This means that if the element isflex-direction: row
on small screens, and responsively changes toflex-direction: column
on larger screens,.center-all-x
must update its behavior accordingly..center-all-x
must work with my modified Tailwind flex and grid classes, which are able to setgap
using.flex/<gap value>
and.grid/<gap value>
classes.
If you just focus on the first requirement and forget the rest, one easy solution comes to mind:
.flex-row.center-all-x {
justify-content: center;
}
.flex-col.center-all-x {
align-items: center;
}
Even if you add requirement #2 (class must have a specificity of 1), it's not too hard, using the :where()
pseudo-selector to help us reset specificity scores:
.flex-row:where(.center-all-x) {
justify-content: center;
}
.flex-col:where(.center-all-x) {
align-items: center;
}
Requirements #3 and #4, though, had me stumped. Here are some examples of the hard problems to solve:
<!--
`.flex-col:where(.center-all-x)` won't work here, because I'm
not applying `.flex-col`, I'm applying `.md:flex-col`.
-->
<div class="flex flex-row md:flex-col center-all-x"></div>
<!--
Likewise, `.flex-row:where(.center-all-x)` won't work here,
because I'm not applying `.flex-row`, I'm applying `.flex-row/6`
(with a modifier to set `gap`).
-->
<div class="flex flex-row/6 center-all-x"></div>
In theory, I could solve the flex-row/6
gap modifier problem by adding a "class contains" selector, and wrapping the whole thing in the :is()
pseudo-selector to maintain 1 point of specificity, like so:
is:(.flex-row, [class*="flex-row/"]):where(.center-all-x) {
justify-content: center;
}
is:(.flex-col, [class*="flex-col/"]):where(.center-all-x) {
align-items: center;
}
But this solution can also give false positives:
<!--
This element will randomly have `justify-content: center` applied,
because it does technically contain the string `flex-row/` and
therefore matches `[class*="flex-row/"]`, even though it's
obviously not the class I'm looking for.
-->
<div class="random-class-that-contains-flex-row/6 center-all-x"></div>
Okay, so let's adjust the selector to assert that class
either contains flex-row/
(with a whitespace in front) or starts with flex-row/
(with no whitespace in front):
is:(.flex-row, [class*=" flex-row/"], [class^="flex-row/"]):where(.center-all-x) {
justify-content: center;
}
is:(.flex-col, [class*=" flex-col/"], [class^="flex-col/"]):where(.center-all-x) {
align-items: center;
}
Alright, that helps us avoid a initial positive with .random-class-that-contains-flex-row/6
, but now we've broken Tailwind variants and responsive modifiers:
<!--
Our selector's weird class string assertions rule out these
perfectly valid Tailwind classes 😭
-->
<div class="flex md:flex-row/6 lg:dark:flex-col/12 center-all-x"></div>
Before you even think about modifying the selector to support variants, remember:
- Any number of Tailwind variants can be chained, in any order
- People can configure their own custom Tailwind variants
- HOLY SHIT this selector is getting complicated, huge, and impossible to compress
- It still doesn't solve #5 (change the behavior of
.center-all-x
based on responsive changes toflex-direction
)
It's edge cases all the way down.
And space toggles will save us.
Let's start from the beginning—we're going to smash requirements 1-3 with just the code in this block:
/**
We put this code in Tailwind's utilities layer, so that
Tailwind will generate new CSS classes with variants as needed.
These custom utility classes will override the default `.flex`,
`.flex-row`, and `.flex-col` classes shipped by Tailwind. This
isn't a problem, because the new classes implement the same basic
features, and just add a few features of their own.
*/
@layer utilities {
.flex-row {
flex-direction: row;
--flex-row: ; // The empty whitespace means TRUE
--flex-col: initial; // `initial` means FALSE
}
.flex-col {
flex-direction: column;
--flex-row: initial; // FALSE
--flex-col: ; // TRUE
}
.center-all-x {
justify-content: var(--flex-row) center;
align-items: var(--flex-col) center;
}
}
Now let's look at some HTML to explain how this plays out:
<!--
`.flex` sets `--flex-row` to ` ` and `--flex-col` to `initial`.
So, inside `.center-all-x`, the declarations resolve to:
justify-content: center;
align-items: initial center;
The `align-items` declaration is invalid, so the browser
discards it. `justify-content` is valid and gets applied, and
child elements are centered horizontally 🎉
-->
<div class="flex flex-row center-all-x"></div>
<!--
On small screens, this example works the exact same as the previous one.
`.md:flex-col` is further down the cascade than `.flex`,
so on `md` screens and above, `.md:flex-col` overwrites the
CSS variable values.
Inside `.center-all-x`, the declarations resolve to:
justify-content: initial center;
align-items: center;
The `justify-content` declaration is invalid, so the browser
discards it. `align-items` is valid and gets applied, and child
elements are still centered horizontally, even though our flex
direction has changed 🤯
-->
<div class="flex flex-row md:flex-col center-all-x"></div>
The final piece of the puzzle—supporting my gap modifier classes—gets solved easily by Tailwind's plugin API, which I use instead of plain CSS to define my utility classes.
Here's a Tailwind playground where you can see it all come together!
The best part: every browser that supports CSS variables also supports the space toggle trick.