yclic Dependency Space Toggles

Over the past few years, I wanted to be able to select a value from a list in CSS by toggling a single custom property. We have the “space toggle” for booleans, and hopefully, one day, we’ll get style queries, but what about today? In this article, I present you with the technique I recently discovered.

he Technique

Let me startGo to a sidenote by showing you the code, and then I will explain the technique (which I would refer to as “Cyclic Toggles”). Look:

.info {
  --level: var(--level--default);

  --level--default: var(--level,);
  --level--success: var(--level,);
  --level--warning: var(--level,);
  --level--error:   var(--level,);

  background:
    var(--level--default, lavender)
    var(--level--success, palegreen)
    var(--level--warning, khaki)
    var(--level--error,   lightpink);
}

This CSS would give the .info a default of a lavender background. If we’d set the --level: var(--level-success) on this element, no matter whereGo to a sidenote: in HTML as an inline style or in CSS via a state or query override, we’d get it to be palegreen.

Here is a live example with the above code that sets the --level variable on the elements in different ways:

The only thing we added regarding the logic is this:

:checked + * + * > .info:first-child {
  --level: var(--level--success);
}
.info-states {
  --level: var(--static);
}
.info-states.info:hover {
  --level: var(--hover);
}

And then here is the corresponding HTML:

<input
  type="checkbox"
  id="e1-checkbox"
/><label for="e1-checkbox"> Check me</label>
<div class="example-1">
  <div class="info"></div>
  <div
    class="info"
    style="--level: var(--level--success)"
  ></div>
  <div class="info info-states" style="
    --static: var(--level--warning);
    --hover: var(--level--error);
  "></div>
</div>

We can see how we can use different methods to “pass” the value to our toggle variable: a CSS state based on a pseudo-class, as an inline style, and even passing it through an additional API level.

We’re not limited by a single property, we can set as many as we want, and we could do the same on the descendants or pseudo-elements:

Here we did add a border-radius and content for the pseudo-element “listening” to the same state, alongside some additional static visual styles.

.example-2 .info {
  border-radius:
    var(--level--default, 0.5rem)
    var(--level--success, 2.5rem)
    var(--level--warning, 0)
    var(--level--error,   0);
  transition: all 0.5s;
}

.example-2 .info::before {
  content:
    var(--level--success, "\2705 ")        /* ✅ */
    var(--level--warning, "\26A0 \FE0F ")  /* ⚠️ */
    var(--level--error,   "\26D4 \FE0F "); /* ⛔️ */
}

And here we are, being able to switch the state through multiple options by toggling a single variable. But…

hat is Going On?

When I first discovered this, I could not believe that it worked and that it looked that simple. But if you did try to follow the logic of what is happening, you could have noticed one thing.

The cyclic dependency.

Let’s look at the first two declarations of our first code example:

--level:          var(--level--default);
--level--default: var(--level,);

Wait a minute… Did we set --level to --level--default, and then… set --level--default to the --level? Isn’t that… invalid?

Yes, it is! When we are cross-referencing custom properties like this, both variables becomeGo to a sidenote guaranteed-invalid value.

Here is a quote from the specs:

[…] This can create cyclic dependencies where a custom property uses a var() referring to itself, or two or more custom properties each attempt to refer to each other.

If there is a cycle in the dependency graph, all the custom properties in the cycle are invalid at computed-value time.

And that behavior is what we want! Let’s now look at the rest of our custom property definitions:

--level--success: var(--level,);
--level--warning: var(--level,);
--level--error:   var(--level,);

The --level here is invalid, which means that all three variables should skip to their fallback values, but the only thing we can see is the trailing commas — unlike the “space toggle”, in the case of the custom property fallbacks we can omit the spaceGo to a sidenote, and get the same effect in the end. Let me quote the specs again:

[…] a bare comma, with nothing following it, must be treated as valid in var(), indicating an empty fallback value.

Note: That is, var(--a,) is a valid function, specifying that if the --a custom property is invalid or missing, the var() should be replaced with nothing.

At this point, we have --level--default as an invalid (due to us cross-referencing it with the --level), and the rest of the properties are valid “nothings”Go to a sidenote.

Finally, we can look at any of our regular property declarations:

background:
  var(--level--default, lavender)
  var(--level--success, palegreen)
  var(--level--warning, khaki)
  var(--level--error,   lightpink);

Three of the variables here do not produce anything but are valid and are not skipping to their fallbacks. And our --level--default is invalid, proceeding to the fallback and applying its value.

If we’d re-assign the --level to another variable, like a --level--success, then the --level--default would become the one resulting in nothing, and the --level--success gets an invalid value, resulting in our declaration switching from one value to another.

And that’s it!

ulti-Select

Wait, but do we need to stop at toggling a single value via our cyclic toggle?

Here is the code for the logic in this example:

.with-some-toggles {
  --is-round:   var(--toggles,);
  --is-pink:    var(--toggles,);
  --has-shadow: var(--toggles,);

  border-radius: var(--is-round, 50%);
  background:    var(--is-pink,  pink);
  box-shadow:    var(--has-shadow,
    5px 5px 2px 4px var(--THEME_BG--DISTANT)
  );
}
  <div class="with-some-toggles" style="
    --toggles: var(--is-pink);
  "></div>
  <div class="with-some-toggles" style="
    --toggles: var(--is-round) var(--is-pink);
  "></div>
  <div class="with-some-toggles" style="
    --toggles: var(--is-round) var(--has-shadow);
  "></div>
  <div class="with-some-toggles" style="
    --toggles:
      var(--is-round)
      var(--is-pink)
      var(--has-shadow);
  "></div>

By mentioning multiple values for our cyclic toggles, we make each of them invalid, allowing toggling multiple space toggles at once from a single custom property. How cool is that?


The rest of the article can be considered optional — I would delve into the history of the space toggle itself, logic gates, and use cases.

as This Always Possible?

CSS Variables were available to use in all major versions of browsers from like 2016 — how did no oneGo to a sidenote find this technique before? Likely, because this did not work until recently!

I did try to get to the root of it, and I’ll try to present you with an approximate timeline of events as I see it. This technique would not be possible without the past achievements of other peopleGo to a sidenote — we should be grateful for all the advancements, experiments, and shared knowledge that did lead to me discovering this technique in one way or another.

As we can see, the spec changes allowing this did land at the end of 2021, and all browsers implemented them during 2022 — there is a chance you did your experiments trying to achieve this technique before that and went away with nothing.

pace Toggles Logic

The best aspect of the cyclic toggles is that for each value, they produce a regular “space toggle”: at every point, every variable is either invalid or nothing.

Even the toggle variable itself can be a space toggle: we did have a default value in the above examples, but what if we would set it to nothing? Then it would be valid and thus would pass its value to all the value variables that depend on it, making both the cyclic toggle and all its values to be nothing, allowing the creation of logic based on any of them.

We can almost produce logic gates for our space toggles: we can use logicGo to a sidenote to return final values, but the main issue is that due to the inability to invert a “bit” stored in the space toggle, we cannot make a logic gate that outputs a space toggle itself.

Here is an example that uses our logic gates to compute the backgrounds for each cell.

Input Output
A B AND NAND OR NOR XOR XNOR
0 0 0 1 0 1 0 1
0 1 0 1 1 0 1 0
1 0 0 1 1 0 1 0
1 1 1 0 1 0 0 1

The HTML for each row looks like this:

<tr style="--A: var(--OFF); --B: var(--OFF);">
  <td class="A">0</td>
  <td class="B">0</td>
  <td class="AND">0</td>
  <td class="NAND">1</td>
  <td class="OR">0</td>
  <td class="NOR">1</td>
  <td class="XOR">0</td>
  <td class="XNOR">1</td>
</tr>

For each row, we set the --A and --B variables to one of two values space toggles can accept, and then for each cell in each column, we calculate the background based on them.

uffer and an Impossible Inverter

In “logic gates” terms, we can create a “buffer”, but notGo to a sidenote an “inverter”. However, for the “buffer”, we can define the outcome for both branches of a condition, allowing us to utilize the “not” branch. Here is how it works:

.A {
  --TRUE: palegreen;
  --FALSE: lightpink;

  --NOT-A:    var(--A)              var(--FALSE);
  background: var(--A, var(--TRUE)) var(--NOT-A,);
}

We can see that, to have both values, we need to declare an intermediate variable, where we put our --FALSE value.

Let us calculate this manually for both states of the --A:

  1. If --A is invalid, then
    • --NOT-A variable becomes invalid at computed-value time due to its declaration containing a guaranteed invalid value.
    • var(--A, var(--TRUE)) uses the fallback, outputting the value of --TRUE.
    • var(--NOT-A,) uses the empty fallback, outputting nothing.
    • Combining the two, we get var(--TRUE) and nothing, which results in var(--TRUE).
    • We have palegreen in the end.
  2. If --A is nothing, then
    • --NOT-A variable becomes nothing and var(--FALSE), returning the --FALSE value.
    • var(--A, var(--TRUE)) does not use the fallback and outputs nothing.
    • var(--NOT-A,) gets the value from the --NOT-A, which is --FALSE.
    • We have lightpink in the end.

We cannotGo to a sidenote have an “inverter” here because we have to use valid values for both --FALSE and --TRUE for a buffer for things to not break. When the values are valid, like the colors for the background, things work. But what if the --FALSE or --TRUE would represent two different space toggle values?

To have an “inverter”, we would want to set --TRUE to nothing and --FALSE to invalid. Let’s look at what would happen to our code in this case:

.A {
  --TRUE: ;         /* nothing */
  --FALSE: initial; /* invalid */

  --NOT-A:  var(--A)              var(--FALSE);
  --OUTPUT: var(--A, var(--TRUE)) var(--NOT-A,);
}
  1. If --A is invalid, then
    • --NOT-A has both --A and --FALSE as invalid, returning invalid.
    • var(--A, var(--TRUE)) uses the fallback, outputting the value of --TRUE, which is nothing.
    • var(--NOT-A,) uses the empty fallback, outputting nothing.
    • We have nothing in the --OUTPUT. So far, so good?
  2. If --A is nothing, then
    • Oops, --NOT-A is still invalid, because we have the --FALSE as an invalid value, which makes the whole declaration invalid at computed-value time.
    • var(--A, var(--TRUE)) does not use the fallback and outputs the value of --A, which is nothing.
    • var(--NOT-A,) tries to get the value from the --NOT-A, which, as we found out, is invalid, going to the fallback and outputting nothing.
    • Both parts have nothing in the end.

Regardless of what we put into our equation here, we get nothing, making it impossible to make an inverter.

ND, NAND, OR, NOR, XOR, XNOR

We can also use valid CSS values for all other logic gates. But not the space toggles. As we could later see from an XOR example, we can chain our gates, but this leads to us having to repeat the values for multiple branches, and I’m not sure this is viable or if we can extend it to longer chains.

In the end, both NOR and XNOR are “fake” as we end up flipping which value we’re using in which case, which works for regular values.

I won’t go deep into the details of their implementation but will show the code for them.

Full CSS for the table example’s logic
Input Output
A B AND NAND OR NOR XOR XNOR
0 0 0 1 0 1 0 1
0 1 0 1 1 0 1 0
1 0 0 1 1 0 1 0
1 1 1 0 1 0 0 1
.example-logic-gates {
  --_: var(--_);
  --ON: var(--_);
  --OFF: var(--_,);

  --TRUE: palegreen;
  --FALSE: lightpink;
}

.A {
  --NOT-A: var(--A) var(--FALSE);
  background:
    var(--A, var(--TRUE))
    var(--NOT-A,);
}
.B {
  --NOT-B: var(--B) var(--FALSE);
  background:
    var(--B, var(--TRUE))
    var(--NOT-B,);
}
.AND {
  --A-AND-B: var(--A, var(--B));
  --A-NAND-B: var(--A-AND-B) var(--FALSE);
  background:
    var(--A-AND-B, var(--TRUE))
    var(--A-NAND-B,);
}
.NAND {
  --A-AND-B: var(--A, var(--B));
  --A-NAND-B: var(--A-AND-B) var(--TRUE);
  background:
    var(--A-AND-B, var(--FALSE))
    var(--A-NAND-B,);
}
.OR {
  --A-OR-B: var(--A) var(--B);
  --A-NOR-B: var(--A-OR-B) var(--FALSE);
  background:
    var(--A-OR-B, var(--TRUE))
    var(--A-NOR-B,);
}
.NOR {
  --A-OR-B: var(--A) var(--B);
  --A-NOR-B: var(--A-OR-B) var(--TRUE);
  background:
    var(--A-OR-B, var(--FALSE))
    var(--A-NOR-B,);
}
.XOR {
  --A-AND-B: var(--A, var(--B));
  --A-OR-B: var(--A) var(--B);
  --A-NOR-B: var(--A-OR-B) var(--FALSE);
  --A-NAND-B:
    var(--A-AND-B)
    var(--A-OR-B, var(--TRUE))
    var(--A-NOR-B,);
  background:
    var(--A-AND-B, var(--A-OR-B, var(--FALSE)))
    var(--A-NAND-B,);
}
.XNOR {
  --A-AND-B: var(--A, var(--B));
  --A-OR-B: var(--A) var(--B);
  --A-NOR-B: var(--A-OR-B) var(--TRUE);
  --A-NAND-B:
    var(--A-AND-B)
    var(--A-OR-B, var(--FALSE))
    var(--A-NOR-B,);
  background:
    var(--A-AND-B, var(--A-OR-B, var(--TRUE)))
    var(--A-NAND-B,);
}

A few notes:

allback Values

Another interesting case is when we want to set a default value for a toggle if it is not defined. Let’s modify our example and make the --border toggle have default values based on the --level but still allow us to override it:

In HTML, we can set the --border explicitly on the first element and let the rest have it based on the --level:

<div class="info" style="--border: var(--border--thick);"></div>
<div
  class="info"
  style="--level: var(--level--success)"
></div>
<div class="info info-states" style="
  --static: var(--level--warning);
  --hover: var(--level--error);
"></div>
.example-fallback .info {
  --border: var(--_,);
  --border--none: var(--border,);
  --border--thin: var(--border,);
  --border--thick: var(--border,);

  --_border-fallback:
    var(--border)
    var(--level--default, 0)
    var(--level--success, 0)
    var(--level--warning, 1px)
    var(--level--error,   10px);

  border: solid
    var(--_border-fallback,)
    var(--border--none, 0)
    var(--border--thin, 1px)
    var(--border--thick, 10px);
  }

Here we can see:

  1. I’m using an undefined --_ variable to initialize the --border to nothing.
  2. We define a --_border-fallback that uses the --border and any fallback values (it could be static or, as in the example above, — based on other cyclic toggles).
  3. When the --border is nothing (as in — initially), all its values become nothing as well, but not via a fallback, but by using its valid value, and the --_border-fallback would return its value.
  4. If we’d set the --border to one of its values, it becomes invalid, making the --_border-fallback invalid and changing it to nothing in our result, at the same time “enabling” one of its values that we did set.

In the end, we can have optional toggles with values dependent on other toggles.

o We Still Need Style Queries?

If this method is so simple and powerful, allowing us to use a single custom property to toggle between multiple states of multiple elements, would style queries be redundant?

Short answer: no, of course, style queries would be indispensable; give them to me now.

A longer answer will be in one of my future articles.

Fun fact: the technique in this article came from my experiments with the style queries alternatives. I would write about how it compares to them and to other ways we can work around their absence. Stay tuned!

In the meantime, the limitations that I will be talking about in the next section could hint at how this is not the perfect solution, and there would be a lot of cases where style queries would be much better in almost every aspect.

imitations

This list is not complete — this technique is new, making it possible for us to stumble upon other cases where it would have issues. If you encounter any — let me know, and I will update the article.

nheritance

The main limitation, as I see it, is that if we want to make our cyclic toggle work properly, we have to initiate all the custom properties for the values on the same element as the toggle variable itself.

The explanation comes from the way cyclic dependencies work. Let me quote the specs:

In general, cyclic dependencies occur only when multiple custom properties on the same element refer to each other; custom properties defined on elements higher in the element tree can never cause a cyclic reference with properties defined on elements lower in the element tree.

We have to define everything on the same element, but then we would be free to toggle any of the initiated values via a single custom property. The computed value of our toggle would still propagate to the children, allowing us to adjust the styles of any number of properties on any number of nested elements (unless they redefine all these variables).

This limitation makes for a more strict API, which can be good. However, we need to keep it in mind, as with the regular space toggle (if we were using it for “turning a value on/off”), we could override it on any level.

Style queries would be much more powerful as we could apply them from anywhere via inheritance.

nimations

We cannot toggle a cyclic toggle inside an animation because that would lead to all the values becoming animation-tainted and thus rendering invalid.

aming

Because we have to initiate each possible value as a custom property, there could be a potential for name clashes. That’s why, in my first example, I used BEM-like notation for the values: --level--default instead of --default. While the latter could be used for simple cases, as soon as we want to add a second cyclic toggle, the names could potentially clash.

That is another place where style queries would be superior, as they would allow us to target any custom idents for the custom properties they belong to.

se Cases

If I would try to come up with a lot of examples, I would never finish this article, and I really, really want to share this technique with you now. However, just as a simple list:

There is a chance I will update this section later when I find other good uses for this technique — or you can send me your experiments with it, and I would be happy to add them here. I can’t wait to see what you would come up with!

inal Words

I hope you did find this technique as fascinating as I did and my explanations in the article helpful! I started writing it as soon as I discovered this technique, and there are still places I could improve, but my goal was to share my findings first.

Note that I did not yet use this technique in production (unlike regular space toggles, which work perfectly fine) — if you would try to apply it to your project, be sure to test things extensively.

I cannot wait until I’ll find an excuse to try it for something not critical and see how it could simplify the code for complex cases.

The last thing I’d want to mention once again: this technique won’t be possible without people participating in the community — writing articles, coming up with weird experiments, creatingGo to a sidenote issues in CSSWG GitHub — all this contributes to our shared knowledge and the advancements of CSS.

If you ever found something interesting — share it. And if you want a particular feature — request and advocate for it.

Even if you do not succeed at a particular point, you could write about what you wanted to achieve and see if others would pick it up.

And then, one day, there is a chance your original idea will become a reality.


Let me know what you think about this article on Mastodon!