CSS Custom Properties In The Cascade
CSS Custom Properties In The Cascade
Miriam Suzanne
Last month, I had a conversation on Twitter about the difference between “scoped” styles (generated in a build process) and “nested” styles native to CSS. I asked why, anecdotally, developers avoid the specificity of ID selectors, while embracing “scoped styles” generated by JavaScript? Keith Grant suggested that the difference lies in balancing the cascade* and inheritance, i.e. giving preference to proximity over specificity. Let’s take a look.
The Cascade
The CSS cascade is based on three factors:
- Importance defined by the
!important
flag, and style origin (user > author > browser) - Specificity of the selectors used (inline > ID > class > element)
- Source Order of the code itself (latest takes precedence)
Proximity is not mentioned anywhere — the DOM-tree relationship between parts of a selector. The paragraphs below will both be red, even though #inner p
describes a closer relationship than #outer p
for the second paragraph:
<section id="outer"> <p>This text is red</p> <div id="inner"> <p>This text is also red!</p> </div> </section>
#inner p { color: green; } #outer p { color: red; }
Both selectors have the same specificity, they are both describing the same p
element, and neither is flagged as !important
— so the result is based on source-order alone.
BEM And Scoped Styles
Naming conventions like BEM (“Block__Element—Modifier”) are used to ensure that each paragraph is “scoped” to only one parent, avoiding the cascade entirely. Paragraph “elements” are given unique classes specific to their “block” context:
<section class="outer"> <p class="outer__p">This text is red</p> <div class="inner"> <p class="inner__p">This text is green!</p> </div> </section>
.inner__p { color: green; } .outer__p { color: red; }
These selectors still have the same relative importance, specificity, and source order — but the results are different. “Scoped” or “modular” CSS tools automate that process, re-writing our CSS for us, based on the HTML. In the code below, each paragraph is scoped to its direct parent:
<section outer-scope> <p outer-scope>This text is red</p> <div outer-scope inner-scope> <p inner-scope>This text is green!</p> </div> </section>
p[inner-scope] { color: green } p[outer-scope] { color: red; }
Inheritance
Proximity is not part of the cascade, but it is part of CSS. That’s where inheritance becomes important. If we drop the p
from our selectors, each paragraph will inherit a color from its closest ancestor:
#inner { color: green; } #outer { color: red; }
Since #inner
and #outer
describe different elements, our div
and section
respectively, both color properties are applied without conflict. The nested p
element has no color specified, so the results are determined by inheritance (the color of the direct parent) rather than cascade. Proximity takes precedence, and the #inner
value overrides the #outer
.
But there’s a problem: In order to use inheritance, we are styling everything inside our section
and div
. We want to target the paragraph color specifically.
(Re-)Introducing Custom Properties
Custom properties provide a new, browser-native solution; they inherit like any other property, but they don’t have to be used where they are defined. Using plain CSS, without any naming conventions or build tools, we can create a style that is both targeted and contextual, with proximity taking precedence over the cascade:
p { color: var(--paragraph); } #inner { --paragraph: green; } #outer { --paragraph: red; }
The custom --paragraph
property inherits just like the color
property, but now we have control over exactly how and where that value is applied. The --paragraph
property acts similar to a parameter that can be passed into the p
component, either through direct selection (specificity-rules) or context (proximity-rules).
I think this reveals a potential for custom properties that we often associate with functions, mixins, or components.
Custom “Functions” And Parameters
Functions, mixins, and components are all based on the same idea: reusable code, that can be run with various input parameters to get consistent-but-configurable results. The distinction is in what they do with the results. We’ll start with a striped-gradient variable, and then we can extend it into other forms:
html { --stripes: linear-gradient( to right, powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); }
That variable is defined on the root html
element (could also use :root
, but that adds unnecessary specificity), so our striped variable will be available everywhere in the document. We can apply it anywhere gradients are supported:
body { background-image: var(--stripes); }
Adding Parameters
Functions are used like variables, but define parameters for changing the output. We can update our --stripes
variable to be more function-like by defining some parameter-like variables inside it. I’ll start by replacing to right
with var(--stripes-angle)
, to create an angle-changing parameter:
html { --stripes: linear-gradient( var(--stripes-angle), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); }
There are other parameters we could create, depending on what purpose the function is meant to serve. Should we allow users to pick their own stripe colors? If so, does our function accept 5 different color parameters or only 3 that will go outside-in like we have now? Do we want to create parameters for color-stops as well? Every parameter we add provides more customization at the cost of simplicity and consistency.
There is no universal right answer to that balance — some functions need to be more flexible, and others need to be more opinionated. Abstractions exist to provide consistency and readability in your code, so take a step back and ask what your goals are. What really needs to be customizable, and where should consistency be enforced? In some cases, it might be more helpful to have two opinionated functions, rather than one fully-customizable function.
To use the function above, we need to pass in a value for the --stripes-angle
parameter, and apply the output to a CSS output property, like background-image
:
/* in addition to the code above… */ html { --stripes-angle: 75deg; background-image: var(--stripes); }
Inherited Versus Universal
I defined the --stripes
function on the html
element out of habit. Custom properties inherit, and I want my function available everywhere, so it makes some sense to put it on the root element. That works well for inheriting variables like --brand-color: blue
, so we might also expect it to work for our “function” as well. But if we try to use this function again on a nested selector, it won’t work:
div { --stripes-angle: 90deg; background-image: var(--stripes); }
The new --stripes-angle
is ignored entirely. It turns out we can’t rely on inheritance for functions that need to be re-calculated. That’s because each property value is computed once per element (in our case, the html
root element), and then the computed value is inherited. By defining our function at the document root, we don’t make the entire function available to descendants — only the computed result of our function.
That makes sense if you frame it in terms of the cascading --stripes-angle
parameter. Like any inherited CSS property, it is available to descendants but not ancestors. The value we set on a nested div
is not available to a function we defined on the html
root ancestor. In order to create a universally-available function that will re-calculate on any element, we have to define it on every element:
* { --stripes: linear-gradient( var(--stripes-angle), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); }
The universal selector makes our function available everywhere, but we can define it more narrowly if we want. The important thing is that it can only re-calculate where it is explicitly defined. Here are some alternatives:
/* make the function available to elements with a given selector */ .stripes { --stripes: /* etc… */; } /* make the function available to elements nested inside a given selector */ .stripes * { --stripes: /* etc… */; } /* make the function available to siblings following a given selector */ .stripes ~ * { --stripes: /* etc… */; }
This can be extended with any selector logic that doesn’t rely on inheritance.
Free Parameters And Fallback Values
In our example above, var(--stripes-angle)
has no value and no fallback. Unlike Sass or JS variables that must be defined or instantiated before they are called, CSS custom properties can be called without ever being defined. This creates a “free” variable, similar to a function parameter that can be inherited from the context.
We can eventually define the variable on html
or :root
(or any other ancestor) to set an inherited value, but first we need to consider the fallback if no value is defined. There are several options, depending on exactly what behavior we want
- For “required” parameters, we don’t want a fallback. As-is, the function will do nothing until
--stripes-angle
is defined. - For “optional” parameters, we can provide a fallback value in the
var()
function. After the variable-name, we add a comma, followed by the default value:
var(--stripes-angle, 90deg)
Each var()
function can only have one fallback — so any additional commas will be part of that value. That makes it possible to provide complex defaults with internal commas:
html { /* Computed: Hevetica, Ariel, sans-serif */ font-family: var(--sans-family, Hevetica, Ariel, sans-serif); /* Computed: 0 -1px 0 white, 0 1px 0 black */ test-shadow: var(--shadow, 0 -1px 0 white, 0 1px 0 black); }
We can also use nested variables to create our own cascade rules, giving different priorities to the different values:
var(--stripes-angle, var(--global-default-angle, 90deg))
- First, try our explicit parameter (
--stripes-angle
); - Fallback to a global “user default” (
--user-default-angle
) if it’s available; - Finally, fallback to our “factory default”
(90deg
).
By setting fallback values in var()
rather than defining the custom property explicitly, we ensure that there are no specificity or cascade restrictions on the parameter. All the *-angle
parameters are “free” to be inherited from any context.
Browser Fallbacks Versus Variable Fallbacks
When we’re using variables, there are two fallback paths we need to keep in mind:
- What value should be used by browsers without variable support?
- What value should be used by browsers that support variables, when a particular variable is missing or invalid?
p { color: blue; color: var(--paragraph); }
While old browsers will ignore the variable declaration property, and fallback to blue
— modern browsers will read both and use the latter. Our var(--paragraph)
might not be defined, but it is valid and will override the previous property, so browsers with variable support will fallback to the inherited or initial value, as if using the unset
keyword.
That may seem confusing at first, but there are good reasons for it. The first is technical: browser engines handle invalid or unknown syntax at “parse time” (which happens first), but variables are not resolved until “computed-value time” (which happens later).
- At parse time, declarations with invalid syntax are ignored completely — falling back on earlier declarations. This is the path that old browsers will follow. Modern browsers support the variable syntax, so the previous declaration is discarded instead.
- At computed-value time the variable is compiled as invalid, but it’s too late — the previous declaration was already discarded. According to the spec, invalid variable values are treated the same as
unset
:
html { color: red; /* ignored as *invalid syntax* by all browsers */ /* - old browsers: red */ /* - new browsers: red */ color: not a valid color; color: var(not a valid variable name); /* ignored as *invalid syntax* by browsers without var support */ /* valid syntax, but invalid *values* in modern browsers */ /* - old browsers: red */ /* - new browsers: unset (black) */ --invalid-value: not a valid color value; color: var(--undefined-variable); color: var(--invalid-value); }
This is also good for us as authors, because it allows us to play with more complex fallbacks for the browsers that support variables, and provide simple fallbacks for older browsers. Even better, that allows us to use the null
/undefined
state to set required parameters. This becomes especially important if we want to turn a function into a mixin or component.
Custom Property “Mixins”
In Sass, the functions return raw values, while mixins generally return actual CSS output with property-value pairs. When we define a universal --stripes
property, without applying it to any visual output, the result is function-like. We can make that behave more like a mixin, by defining the output universally as well:
* { --stripes: linear-gradient( var(--stripes-angle), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); background-image: var(--stripes); }
As long as --stripes-angle
remains invalid or undefined, the mixin fails to compile, and no background-image
will be applied. If we set a valid angle on any element, the function will compute and give us a background:
div { --stripes-angle: 30deg; /* generates the background */ }
Unfortunately, that parameter-value will inherit, so the current definition creates a background on the div
and all descendants. To fix that, we have to make sure the --stripes-angle
value doesn’t inherit, by resting it to initial
(or any invalid value) on every element. We can do that on the same universal selector:
* { --stripes-angle: initial; --stripes: /* etc… */; background-image: var(--stripes); }
Safe Inline Styles
In some cases, we need the parameter to be set dynamically from outside CSS — based on data from a back-end server or front-end framework. With custom properties, we can safely define variables in our HTML without worrying about the usual specificity issues:
<div style="--stripes-angle: 30deg">...</div>
Inline styles have a high specificity, and are very hard to override — but with custom properties, we we have another option: ignore it. If we set the div to background-image: none
(for example) that inline variable will have no impact. To take it even farther, we can create an intermediate variable:
* { --stripes-angle: var(--stripes-angle-dynamic, initial); }
Now we have the option to define --stripes-angle-dynamic
in the HTML, or ignore it, and set --stripes-angle
directly in our stylesheet.
Preset Values
For more complex values, or common patterns we want to re-use, we can also provide a few preset variables to choose from:
* { --tilt-down: 6deg; --tilt-up: -6deg; }
And use those presets, rather than setting the value directly:
<div style="--stripes-angle: var(--tilt-down)">...</div>
This is great for creating charts and graphs based on dynamic data, or even laying out a day planner.
Contextual Components
We can also re-frame our “mixin” as a “component” by applying it to an explicit selector, and making the parameters optional. Rather than relying on the presence-or-absence of --stripes-angle
to toggle our output, we can rely on the presence-or-absence of a component selector. That allows us to set fallback values safely:
[data-stripes] { --stripes: linear-gradient( var(--stripes-angle, to right), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); background-image: var(--stripes); }
By putting the fallback inside the var()
function, we can leave --stripes-angle
undefined and “free” to inherit a value from outside the component. This is a great way to expose certain aspects of a component style to contextual input. Even “scoped” styles generated by a JS framework (or scoped inside the shadow-DOM, like SVG icons) can use this approach to expose specific parameters for outside influence.
Isolated Components
If we don’t want to expose the parameter for inheritance, we can define the variable with a default value:
[data-stripes] { --stripes-angle: to right; --stripes: linear-gradient( var(--stripes-angle, to right), powderblue 20%, pink 20% 40%, white 40% 60%, pink 60% 80%, powderblue 80% ); background-image: var(--stripes); }
These components would also work with a class, or any other valid selector, but I chose the data-
attribute to create a namespace for any modifiers we want:
[data-stripes='vertical'] { --stripes-angle: to bottom; } [data-stripes='horizontal'] { --stripes-angle: to right; } [data-stripes='corners'] { --stripes-angle: to bottom right; }
Selectors and Parameters
I often wish I could use data-attributes to set a variable — a feature supported by the CSS3 attr()
specification, but not yet implemented in any browsers (see the resources tab for linked issues on each browser). That would allow us to more closely associate a selector with a particular parameter:
<div data-stripes="30deg">...</div> /* Part of the CSS3 spec, but not yet supported */ /* attr( , ) */ [data-stripes] { --stripes-angle: attr(data-stripes angle, to right); }
In the meantime, we can achieve something similar by using the style
attribute:
<div style="--stripes-angle: 30deg">...</div> /* The `*=` atttribute selector will match a string anywhere in the attribute */ [style*='--stripes-angle'] { /* Only define the function where we want to call it */ --stripes: linear-gradient(…); }
This approach is most useful when we want to include other properties in addition to the parameter being set. For example, setting a grid area could also add padding and background:
[style*='--grid-area'] { background-color: white; grid-area: var(--grid-area, auto / 1 / auto / -1); padding: 1em; }
Conclusion
When we start to put all these pieces together, it becomes clear that custom properties go far beyond the common variable use-cases we’re familiar with. We’re not only able to store values, and scope them to the cascade — but we can use them to manipulate the cascade in new ways, and create smarter components directly in CSS.
This calls for us to re-think many of the tools we’ve relied on in the past — from naming conventions like SMACSS and BEM, to “scoped” styles and CSS-in-JS. Many of those tools help work around specificity, or manage dynamic styles in another language — use-cases that we can now address directly with custom properties. Dynamic styles that we’ve often calculated in JS, can now be handled by passing raw data into the CSS.
At first, these changes may be seen as “added complexity” — since we’re not used to seeing logic inside CSS. And, as with all code, over-engineering can be a real danger. But I’d argue that in many cases, we can use this power not to add complexity, but to move complexity out of third-party tools and conventions, back into the core language of web design, and (more importantly) back into the browser. If our styles require calculation, that calculation ought to live inside our CSS.
All of these ideas can be taken much further. Custom properties are just starting to see wider adoption, and we’ve only begun to scratch the surface of what’s possible. I’m excited to see where this goes, and what else people come up with. Have fun!
Further Reading
- “It’s Time To Start Using CSS Custom Properties,” Serg Hospodarets
- “A Strategy Guide to CSS Custom Properties,” Michael Riethmuller
Articles on Smashing Magazine — For Web Designers And Developers