26

I'm attempting to scale size via a var custom property in a way that the classes would compose without being coupled. The desired effect is that the 3 lists would be at 3 different scales but as demonstrated on CodePen all 3 lists are the same scale. I'm looking for an explanation of the scoping and a CSS custom property technique that could achieve this with composable loosely coupled code.

:root {
  --size-1: calc(1 * var(--scale, 1) * 1rem);
  --size-2: calc(2 * var(--scale, 1) * 1rem);
  --size-3: calc(3 * var(--scale, 1) * 1rem);
}

.size-1 { font-size: var(--size-1) }
.size-2 { font-size: var(--size-2) }
.size-3 { font-size: var(--size-3) }

.scale-1x { --scale: 1 }
.scale-2x { --scale: 2 }
.scale-3x { --scale: 3 }

html {
  font: 1em sans-serif;
  background: papayawhip;
}

ol {
  float: left;
  list-style: none;
  margin: 1rem;
}
<ol class="scale-1x">
  <li class="size-1">size 1</li>
  <li class="size-2">size 2</li>
  <li class="size-3">size 3</li>
</ol>
<ol class="scale-2x">
  <li class="size-1">size 1</li>
  <li class="size-2">size 2</li>
  <li class="size-3">size 3</li>
</ol>
<ol class="scale-3x">
  <li class="size-1">size 1</li>
  <li class="size-2">size 2</li>
  <li class="size-3">size 3</li>
</ol>
Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
ryanve
  • 50,076
  • 30
  • 102
  • 137
  • 3
    @Temani Afif: Variables are CSS3 because CSS3 is defined by the working group as everything beyond CSS2, not because variables have shipping implementations. "CSS4" is not an official term. Note that Variables start out at level 1. The way people categorize certain level 3 or level 1 features as CSS3 and others as "CSS4" is... fascinating, to put it nicely. – BoltClock Sep 06 '18 at 09:13
  • 1
    If still intrested, I added another idea of solution to my answer that can be a good trick (replacing the `:root` with the universal selector `*`) – Temani Afif Sep 27 '20 at 13:38

2 Answers2

35

In your case you have evaluated the --scale custom property at the root level to define the --size-* properties and then you defined the --scale again inside child elements. This will not trigger evaluation again because it was already done in an upper level.

Here is a simple example to illustrate the issue:

.box {
  --color: var(--c, blue);
}

span {
  color: var(--color);
}
<div>
  <div class="box"><!-- --c is evaluated at this level -->
    <span style="--c:red">I will not be red because the property is already evaluated and --color is set to blue using the default value</span>
  </div>
</div>

<div style="--c:red">
  <div class="box"><!-- --c is evaluated at this level -->
    <span>I will be red because at the time of the evaluation --c is red (inherited from the upper div)</span>
  </div>
</div>

To fix your issue, you need to move the declaration from :root to the same level as the --scale definition:

.scale {
  --size-1: calc(1 * var(--scale, 1) * 1rem);
  --size-2: calc(2 * var(--scale, 1) * 1rem);
  --size-3: calc(3 * var(--scale, 1) * 1rem);
}

.size-1 { font-size: var(--size-1) }
.size-2 { font-size: var(--size-2) }
.size-3 { font-size: var(--size-3) }

.scale-1x { --scale: 1 }
.scale-2x { --scale: 2 }
.scale-3x { --scale: 3 }


html {
  font: 1em sans-serif;
  background: papayawhip;
}

ol {
  float: left;
  list-style: none;
  margin: 1rem;
}
<ol class="scale-1x scale">
  <li class="size-1">size 1</li>
  <li class="size-2">size 2</li>
  <li class="size-3">size 3</li>
</ol>
<ol class="scale-2x scale">
  <li class="size-1">size 1</li>
  <li class="size-2">size 2</li>
  <li class="size-3">size 3</li>
</ol>
<ol class="scale-3x scale">
  <li class="size-1">size 1</li>
  <li class="size-2">size 2</li>
  <li class="size-3">size 3</li>
</ol>

In this case, --scale is defined at the same level as its evaluation so --size-* will be defined correctly for each case.


From the specification:

To substitute a var() in a property’s value:

  1. If the custom property named by the first argument to the var() function is animation-tainted, and the var() function is being used in the animation property or one of its longhands, treat the custom property as having its initial value for the rest of this algorithm.
  2. If the value of the custom property named by the first argument to the var() function is anything but the initial value, replace the var() function by the value of the corresponding custom property. Otherwise,
  3. if the var() function has a fallback value as its second argument, replace the var() function by the fallback value. If there are any var() references in the fallback, substitute them as well.
  4. Otherwise, the property containing the var() function is invalid at computed-value time

In your first situation, you are falling into 3 because there is no value specified for --scale at the root level. In the last case we are falling into 2 because we defined --scale at the same level and we have its value.


In all the cases, we should avoid any evaluation at :root level because it's simply useless. The root level is the uppermost level in the DOM so all the elements will inherit the same value and it's impossible to have different values inside the DOM unless we evaluate the variable again.

Your code is equivalent to this one:

:root {
  --size-1: calc(1 * 1 * 1rem);
  --size-2: calc(2 * 1 * 1rem);
  --size-3: calc(3 * 1 * 1rem);
}

Let's take another example:

:root {
  --r:0;
  --g:0;
  --b:255;
  --color:rgb(var(--r),var(--g),var(--b))
}
div {
  color:var(--color);
}
p {
  --g:100;
  color:var(--color);
}
<div>
  some text
</div>
<p>
  some text
</p>

Intuitively, we may think that we can change the --color by changing one of the 3 variables defined at :root level but we cannot do this and the above code is the same as this one:

:root {
  --color:rgb(0,0,255)
}
div {
  color:var(--color);
}
p {
  --g:100;
  color:var(--color);
}
<div>
  some text
</div>
<p>
  some text
</p>

The 3 variables (--r, --g, --b) are evaluated inside :root so we have already substituted them with their values.

In such a situation we have 3 possibilities:

  • Change the variables inside the :root using JS or another CSS rule. This won't allow us to have different colors:

:root {
  --r:0;
  --g:0;
  --b:255;
  --color:rgb(var(--r),var(--g),var(--b))
}
div {
  color:var(--color);
}
p {
  --g:200; /*this will not have any effect !*/
  color:var(--color);
}

:root {
  --g:200; /*this will work*/
}
<div>
  some text
</div>
<p>
  some text
</p>
  • Evaluate the variable again inside the needed element. In this case, we will lose any kind of flexibility and the definition inside :root will become useless (or at least will become the default value):

:root {
  --r:0;
  --g:0;
  --b:255;
  --color:rgb(var(--r),var(--g),var(--b))
}
div {
  color:var(--color);
}
p {
  --g:200;
  --color:rgb(var(--r),var(--g),var(--b));
  color:var(--color);
}
<div>
  some text
</div>
<p>
  some text
</p>
  • Change the :root selector with the universal selector *. This will make sure our function is defined and evaluated at all the levels. In some complex case, this may have some unwanted results

* {
  --r:0;
  --g:0;
  --b:255;
  --color:rgb(var(--r),var(--g),var(--b))
}
div {
  color:var(--color);
}
p {
  --g:200;
  color:var(--color);
}
<div>
  some text
</div>
<p>
  some text
</p>

Considering this, we should always keep the evaluation at the lowest possible point in the DOM tree and especially after the variable changes (or at the same level)

Here is what we shouldn't do

:root {
  --r: 0;
  --g: 0;
  --b: 0;
}
.color {
  --color: rgb(var(--r), var(--g), var(--b))
}
.green {
  --g: 255;
}
.red {
  --r: 255;
}
p {
  color: var(--color);
}

h1 {
  border-bottom: 1px solid var(--color);
}
<div class="color">
  <h1 class="red">Red </h1>
  <p class="red">I want to be red :(</p>
</div>
<div class="color">
  <h1 class="green">Green </h1>
  <p class="green">I want to be green :(</p>
</div>

Here is what we should do

:root {
  --r:0;
  --g:0;
  --b:0;
}
.color {
  --color:rgb(var(--r),var(--g),var(--b));
}

.green {
  --g:255;
}

.red {
  --r:255;
}

p {
  color:var(--color);
}
h1 {
  border-bottom: 1px solid var(--color);
}
<div class="red">
  <h1 class="color">Red title</h1>
  <p class="color">Yes I am red :D</p>
</div>
<div class="green">
  <h1 class="color">Green title</h1>
  <p class="color">Yes I am green :D</p>
</div>

We can also do like this:

:root {
  --r:0;
  --g:0;
  --b:0;
}
.color {
  --color:rgb(var(--r),var(--g),var(--b));
}

.green {
  --g:255;
}

.red {
  --r:255;
}

p {
  color:var(--color);
}
h1 {
  border-bottom: 1px solid var(--color);
}
<div class="red color">
  <h1 >Red title</h1>
  <p >Yes I am red :D</p>
</div>
<div class="green color">
  <h1>Green title</h1>
  <p >Yes I am green :D</p>
</div>
Temani Afif
  • 245,468
  • 26
  • 309
  • 415
  • Right but if CSS vars are actually live then why doesn't it inherit and evaluate? I'd like to avoid having the scale classes have knowledge of the size vars. Is there a way? – ryanve Aug 25 '18 at 09:32
  • 6
    @ryanve because CSS goes from top to bottom ... imagine you are using inherit, can a parent element inherit from its child element? no ... same logic here, the root doesn't see the --scale property at the time of the evaluation. So if a child element change a property we will not get backwards to re evaluate all the parent properties, this will create cycle and it won't work – Temani Afif Aug 25 '18 at 09:44
  • @TemaniAfif I agree with what you said in the last comment, but that doesn't mean things like https://stackoverflow.com/questions/63459791/ should not be possible. In that example, the child does not (attempt to) re-write the parent value, but it still doesn't work. Your answer here shows that we can only make re-usable CSS "functions" that depend on the DOM structure (or markup), but we can not make re-usable "functions" that are re-usable within CSS without any reliance on DOM structure (markup). It means re-usability is strictly coupled to DOM structure, which complicates re-usability. – trusktr Aug 17 '20 at 23:36
  • @TemaniAfif Basically what I mean is, as in this example (https://codepen.io/trusktr/pen/c3f10cf692bb40de403f6374cd9c54f1), there is no way to give the CSS author a re-usable function without requiring them to modify the HTML markup. There is no way to allow them to stay strictly within the CSS; they _must_ use the `sum` class in the markup to achieve re-usability. – trusktr Aug 17 '20 at 23:39
  • @trusktr *In that example, the child does not re-write the parent value,* --> it's not about re-writing, it's about scope. An element doesn't see the inner scope defined in its child so changing the value inside a child can never change a value inside a parent element. CSS goes from top to bottom. What you want is impossible in the way you want it. Using function with CSS variables inside root is useless and you have no way to overcome this (the only ways are the ones I described in this answer) – Temani Afif Aug 17 '20 at 23:41
  • 1
    Thanks. I get it, though it was definitely not intuitive. But most importantly and unfortunately, it couples re-use to the HTML markup. Wish there was a re-use pattern so any CSS author could re-use a "function" without coupling it to HTML markup. – trusktr Aug 17 '20 at 23:46
-1

Based on the info from the other answer, you could simply add [class^="scale-"] along with the root so it gets recalculated on elements starting with class "scale-"

:root,
[class^="scale-"] {
  --size-1: calc(1 * var(--scale, 1) * 1rem);
  --size-2: calc(2 * var(--scale, 1) * 1rem);
  --size-3: calc(3 * var(--scale, 1) * 1rem);
}

Working: https://codepen.io/gpoitch/pen/YzjoYNX

gdub
  • 793
  • 9
  • 16