Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-variables?] Higher level custom properties that control multiple declarations #5624

Open
LeaVerou opened this issue Oct 15, 2020 · 73 comments

Comments

@LeaVerou
Copy link
Member

LeaVerou commented Oct 15, 2020

Currently, custom properties can be used to hold small pieces of data to be used as parts of larger values. Despite being called custom properties, they are mainly used as variables. High-level custom properties that control a number of other CSS properties cannot be implemented.

Besides limiting regular CSS authors, this makes it impossible for custom element authors to follow the TAG guideline to avoid presentational attributes and to use a custom property instead, except for very simple bits of data like fonts, colors, and lengths. For anything more complex, web component authors use attributes instead.

Examples from a variety of custom element libraries:

I can collect more if needed, examples abound in nearly all component libraries. Currently, these are impossible to implement as CSS custom properties, for a number of reasons:

  • Each attribute controls multiple declarations, in different ways than just simple value substitution
  • The actual value of the attribute may not be used used anywhere directly.
  • Even in cases where the attribute controls one value only, it is more high level than the corresponding declarations (e.g. pill=on vs border-radius: 999px).

Essentially, component authors need more high-level custom properties that encapsulate the corresponding declarations better instead of just containing fragments of values.

Some proposals to address this problem focus on a JS-based way to monitor property changes [WICG/webcomponents#856], but that appears to be hard to implement. So, I'm wondering if we can address this from CSS instead, especially since it would also address a number of other use cases too that are unrelated to components, a big one being mixins, without the problems that we had with @apply.

There are discussions in the group about inline conditionals [#4731, #5009]. If we were to have such conditionals, these would be possible to implement, but very painful (each declaration value would need to be one or more if()). I was wondering if we could simplify this.

A pseudo-class such as :if-var(<dashed-ident> <comparison-operator> <value>) would solve this ideally, but would likely not be implementable due to cycles. OTOH we already do cycle detection for variables, so perhaps it is? If so, I can flesh out a proposal.

Otherwise, perhaps a nested @rule?

my-input {
	@if-var(--pill = on) {
		border-radius: 999px;
	}
}

With nesting, that would even allow multiple rules, so it would cater for use cases such as e.g. the tabs placement without too much repetition.

One way to implement this would be as sugar for multiple if()s, but that would have the undesirable side effect of all containing declarations being set to initial when the conditional doesn't match and there's no @else, which is suboptimal.

Whatever solution we come up with, some things to consider:

  • The applied declarations may span multiple rules
  • The actual value of the property may only be used indirectly, in conditionals (e.g. --size: [small | medium | large]) or it may be used directly in some values, and also in conditionals.

Current status (updated April 5th, 2021)

This is a long discussion, this is the current status:

  • The initial idea of desugaring to inline if() has several drawbacks and would end up being very confusing for authors.
  • Another idea is to introduce a new type of custom property (custom constant?) that is limited to keywords and numbers, and can be used in a :const() selector. This requires two cascade passes. Constants cannot be set based on :const() selectors.
  • Another idea, proposed by an actual implementer is to use regular custom properties, and have a pseudo that is invalid on the subject compound. This so far seems to address the most use cases.
@jonathantneal
Copy link
Contributor

I love the idea here because the conditions may be created and referenced from within CSS.

This shares some thematic similarity with the custom :state() pseudo-class. They both help authors style by custom conditions, and both without being directly reflected in serialized HTML. While :state() differs in by using selectors and not supporting assignment within CSS, I’m sharing it so we can glean from their similarities and from feedback on :state(), as that one is already shipping behind a flag in Chrome at the time of this writing.

@Que-tin
Copy link

Que-tin commented Nov 3, 2020

I think we have to be careful with the naming of the expression, independent of how it's actually implemented. I like the approach of the @if-var but I really don't like the naming, as we don't know if we may expect constants or anything other than variables in CSS in the near or distant future.

Thinking about it I'd like to have a universally @if that could be used with all types of values not only with custom properties. That way we could use it with other already existing stuff like env().

e.g.

.my-input {
	@if(var(--pill) = on) {
		border-radius: 999px;
	}
}

.my-input {
	@if(env(safe-area-inset-left) = 20) {
		border-radius: 10px;
	}
}

Thought even further it would be possible to redefine already existing properties. Like background kinda did for background-color. Imagine exposing the screen width or anything to the user agent with like env(width).

That way it would be possible to combine conditions of each kind and even write media queries and media conditions as part of @if conditions.

e.g.

.my-input {
	@if(env(width) > 768) {
		border-radius: 10px;
	}
}

.my-input {
	@if(env(width) > 768) and (var(--pill) = on) {
		border-radius: 999px;
	}
}

@LeaVerou
Copy link
Member Author

LeaVerou commented Nov 3, 2020

@Que-tin Naming is typically decided at the end, all features are proposed with an implied "name to be bikeshedded".

The problem with such a generic conditional is — as usual — cycles.

Consider this:

.my-input {
	@if (env(width) > 768px) {
		width: 700px;
	}
}

Also, with such generic expressions, there are ambiguities. E.g. what does @if (calc(env(width) + env(height)) > 100%) mean, since percentages mean different things in each property?

@LeaVerou
Copy link
Member Author

LeaVerou commented Nov 3, 2020

A pseudo-class such as :if-var(<dashed-ident> <comparison-operator> <value>) would solve this ideally, but would likely not be implementable due to cycles. OTOH we already do cycle detection for variables, so perhaps it is? If so, I can flesh out a proposal.

It would be good to hear from implementors about this, and in general solicit feedback about a possible way forwards that is implementable and covers most use cases, so I'm gonna go ahead and Agenda+ this.

Note to chairs: I cannot attend the APAC call, so this would need to be next week.

@Que-tin
Copy link

Que-tin commented Nov 3, 2020

Good point. There are actually a few exceptions to make at this point, hence percentages as well as integers, should have no meaning at all in this case (seen relative to the element the condition is used in). It would be hard to consider when values are relative and when absolute to the element they are used in inside of the if condition.

The advantages in my example above would actually be that you could use media conditions (if implemented as e.g. env(width) in the user agent) as part of the CSS properties as already know in e.g. SASS.
It would also be possible to use things like attr() in the future.

@andruud
Copy link
Member

andruud commented Dec 1, 2020

my-input {
        border-radius: 0px;
	@if-var(--pill = on) {
		border-radius: 999px;
	}
}

#my-input {
       --pill: off;
}

@LeaVerou Just to make sure I understand the proposal, for <my-input id=my-input> you'd expect a border-radius of 0px, right?

@andruud
Copy link
Member

andruud commented Dec 1, 2020

(... interpreting that upvote as a "yes"):

Having selector matching (@if-var is not very different from a nested :if-var) depend on computed-value-time things adds far too much complexity, even if it's not impossible.

IMO if we want to if-else on custom properties, then that evaluation needs to happen computed value time, i.e. if-else needs to be part of the value. Or we need something which translates into effectively doing that internally. Or, we add a new kind of custom property which can be known selector matching time.

@emilio
Copy link
Collaborator

emilio commented Dec 1, 2020

I agree with @andruud fwiw. I don't understand why something like this:

There are discussions in the group about inline conditionals [#4731, #5009]. If we were to have such conditionals, these would be possible to implement, but very painful (each declaration value would need to be one or more if()). I was wondering if we could simplify this.

Is really more painful / complex than what's being proposed here.

Basically, something like border-radius: if(var(--pill), 0px, 999px); seems simpler and much less action-at-a-distance to me.

In fact if you make --pill a boolean variable with values 0 or 1, you can already accomplish this particular usecase (border-radius: calc(var(--pill) * 999px)), though I understand there are more complex use cases that aren't probably covered by such a thing (and also it is a bit more obscure than some more explicit syntax).

@LeaVerou
Copy link
Member Author

LeaVerou commented Dec 2, 2020

@emilio --pill might not be a good example, as it only needs to control one property, so indeed one if() serves that case just fine. However many of them need to control multiple, often across several rules. I listed a lot more in my original post. Also, once you have several of these custom properties controlling intersecting sets of properties, solving it with if() suffers from combinatorial explosion. Lastly, with if(), you need to provide an alternative (either explicitly or implicitly), whereas a lot of these are about additional "traits" that only set certain declarations when they are actually specified.

Idea: Would it be easier to implement if one was only able to set other custom properties in these conditionals?

@css-meeting-bot
Copy link
Member

The CSS Working Group just discussed [css-variables?] Higher level custom properties that control multiple declarations.

The full IRC log of that discussion <dael> Topic: [css-variables?] Higher level custom properties that control multiple declarations
<dael> github: https://github.com//issues/5624
<dael> leaverou: There is a very reasonable tag guideline that custom elements shoudl use properties for presentational elements. With current state of custom properties this is impossible for non-trivial
<dael> leaverou: Current custom prop can only be literal fragments and you need to transform
<dael> leaverou: There are problems where we need to add inline conditionals.
<dael> leaverou: However, when you have lots of these properties intersecting then it can get really messy if only 2 you have is inline funcitons that need both condisions.
<dael> leaverou: Ideal is something to cascade but not sure feasible. Wanted impl feedback and then I can draft a more detailed proposal
<dael> leaverou: Examples I've looks at from component liberties are in the issue
<dael> leaverou: Some impl have weighed in in the issue [missed]
<dael> leaverou: Wondering if set of constraints could be introduced to make it more feasable. Wanted to bring to attention of group for more ideas or thoughts
<dael> astearns: Feedback on the shape of the feature?
<dael> fremy: I think it's a pretty good idea.
<dael> fremy: Really something that's a limitation of custom properties. Quite true when you use attributes you do more than reuse variable.
<dael> fremy: Pseudo class you can't use in theory but it would be really nice to have syntactic sugar. I know you can do it meta languages. Good to auto-prefix with an if condition. That would give us most of advenatages. Extend the css selector syntax to have an id for all properties
<TabAtkins> Assuming we're fine with simple conditionals in an if() function (which we've discussed before and this should be okay), doing an at-rule inside holding a block of props could have a reasonable desugaring to that.
<dael> leaverou: One thing to keep in mind is these often need to control multiple elements in the component. Alignment might need to control margins and padding. Ideally should work
<dael> leaverou: [missed]
<TabAtkins> It would have some side-effects - any properties in the block are effectively using a variable, so it would kick in IACVT behavior, etc.
<dael> leaverou: Often they need to control properties in mutli elements. Example alignment controls spaces, padding, etc in multi child elements. Good to keep in mind that it plays nicely with nesting module
<dael> TabAtkins: invalid at computed value time = iacvt
<fremy> @tabatkins: I would think it solves many issues
<dael> leaverou: Some reasonable syntax to combine and falling back to invalid at computed value time would prob be acceptable
<leaverou> As long as we can combine conditions and nest them, having IACVT as the ultimate fallback is acceptable
<TabAtkins> so like `.foo { color: blue; @if (var(--state) = one) { color: red; } }`, it'd desugar to `.foo { color: cond(var(--state) = one, red; blue); }`
<dael> astearns: I think this is a really interesting proposal and I'd like to see further discussion on what we can do here. Any major concerns about spending time on this?
<dael> astearns: I think we should take this back to the issue and/or come up with a proposal which we can file issues on. IT's a really good idea. Anything else from group?
<dael> leaverou: I primarily wanted to draw impl attention. I can't design impl needs. Continue on issue is fine

@FremyCompany
Copy link
Contributor

As mentioned above, I would like this to be pursued further. That sounds very useful (but even having if would be nice, I think).

Random thought: another possible syntax for the syntactic sugar:

selector {
    property-one: value;
    @transform-values if(--condition: true, $);
    property-two: value-x, value-y;
    property-three: value-u, value-v;
}

In the CSSOM, the @transform-values wouldn't be reflected, it would be a transform applied while parsing declarations.

selector {
    property-one: value;
    property-two: if(--condition: true, value-x, value-y);
    property-three: if(--condition: true, value-u, value-v);
}

@tabatkins
Copy link
Member

So we'd previously discussed the cond() function as one of the "switch" variants, which would have effectively these exact semantics but grouped differently. That is, given Anders' example (lightly edited):

my-input {
    border-radius: 0px;
	@if (var(--pill) = on) {
		border-radius: 999px;
	}
}

it would be equivalent to the following using cond():

my-input {
    border-radius: cond((var(--pill) = on) 999px; 0px);
}

(I wrote cond() as being essentially a math function, but simple equivalence for keywords seems reasonable to mix into here. These might want to use additional non-math comparators, like "is the value in this set", so we'd have to give it some thought and care.)

The benefit of the at-rule syntax is that it inverts the grouping - when you have a bunch of variants of several properties, the at-rule groups them by variant, while cond() groups them by property. Which is more readable varies case-by-case, but when it matters it can have a large effect on the readability, particularly when the variants don't all affect the exact same set of properties. Noticing that sort of variation can be very hard when looking across several cond() functions.

If we treat the two as exactly equivalent, just sugar variations of each other, then this does end up implying some slightly non-obvious behavior in some cases. For example, in:

my-input {
	@if (var(--pill) = on) {
		color: green;
	}
}

(Note the lack of "default" color in the block.) Then if --pill is off or whatever, color wouldn't just be not set, it would be IACVT and end up setting itself to inherit, which has some minor cascading implications. (This is presumably the behavior we'd define for cond() if you don't provide a default clause.)

I don't think this is a big deal, it's just worth understanding the implications. I think this is much better than defining this as an almost identical feature that actually works completely differently under the covers.

@FremyCompany
Copy link
Contributor

Regarding Tab's proposal, we can do slightly better, and copy the value preceding the @if as a fallback:

my-input {
        color: blue;
	@if (var(--pill) = on) {
		color: green;
	}
}

would be either blue or green, by filling the fallback part of the cond with the already-existing value in the declaration (and only in the declaration, not across another selector, so that would still be a limitation, just a more convenient one because it allows to specify a default.

@tabatkins
Copy link
Member

Yes, that exactly what the first part of my post was implying - you'd collect a given property across all the if-blocks and "plain", and group them into a cond() (with the "plain" version being the final default branch of the cond()).

@brandonferrua
Copy link

I think this is a great idea and would find it very useful in our applications as we author them today.

We might author a web component that provides an interface to change the border radius of part=foo through var(--border-radius).

I'd assume through a conditional, we can expose an additional interface that would inherit a custom property from an ancestor.

@if(conditional: true) {
  --prop: var(--new-prop);
}

Based on the conditional, a component we author might take the shape of the following:

<your-element>
  #shadow-root
    <style>
      [part=foo] {
        border-radius: var(--border-radius, 0);
        @if(--theme: bubbles) {
          /* inherits --app-border-radius from ancestor such as :root */
          --border-radius: var(--app-border-radius, 15px);
        }
      }
    </style>
    <div part="foo">Text</div>
</your-element>

This would give our customers to control the behavior of --theme: bubbles outside of the component they don't own.

Since CSS custom properties inherit through shadow trees, my assumption is a customer can gain better control by changing CSS contextually in their component.

Their app might define 30px for all border-radius.

:root {
  --theme: bubbles;
  @if(--theme: bubbles) {
    --app-border-radius: 30px;
  }
}

But contextually, they want to target the prop from ::part(foo) and override the applications border-radius of 30px to be 4px for the instance of <my-element>.

<my-element>
  #shadow-root
    <style>
      ::part(foo) {
        @if(--theme: bubbles) {
          --border-radius: 4px;
        }
      }
    </style>
    <!-- customer does not own this component -->
    <your-element></your-element>
</my-element>

I highlight this example to capture how Salesforce would share styles between components but expose additional control for our customers.

@Que-tin
Copy link

Que-tin commented Dec 9, 2020

@tabatkins How will nested conditions be written?

I saw this example of yours in the linked issue of course I think commas make more sense here.

margin-left: cond((50vw < 400px) 2em, (50vw < 800px) 1em, 0px);

But what if I turn the cond around? This makes it quite unreadable in my opinion especially the comma-separated list of possible values. Imagine having more than two conditions nested in each other

margin-left: cond((50vw < 400px) (50vw < 800px) 1em, 0px, 2em);

Or will it be possible to do smth like this to increase readability? Should be, correct?

margin-left: cond((50vw < 400px) ((50vw < 800px) 1em, 0px), 2em);

But how about the at-rule?

my-input {
  border-radius: 0;
  @if (var(--pill) = on) {
    @if (var(--half) = on) {
      border-radius: 10px;
    }
    border-radius: 999px
   }
}

Or will it be smth like this:

my-input {
  border-radius: 0;
  @if (var(--pill) = on) {
    border-radius: 999px
   }
  @if ((var(--pill) = on) (var(--half) = on) {
    border-radius: 10px;
  }
}

May it be possible to also implement some kind of logical operators for the conditions? AND, OR and NOT would be amazing to have in both solutions. I mean, of course, it would be possible to achieve this even without the operators, it just would increase the readability a lot.

my-input {
  border-radius: cond((var(--pill) = on) and (var(--round) = on) 999px; 0px);
}

my-input {
  border-radius: 0;
  @if (var(--pill) = on) and (var(--round) = on)) {
    border-radius: 999px
   }
}

I think this is one of the most complex proposals up to date as there are so many different approaches and solutions as well as things that have to be paid attention to.

@LeaVerou
Copy link
Member Author

IACVT could work for components, since presumably all their styles are defined in the same place. Assuming these rules can be nested with predictable results, and do take all values defined in the rule into account before triggering IACVT (per @FremyCompany and @brandonferrua's suggestions), I don't think the downsides of IACVT will come into play too frequently. However, especially in that case, nesting support becomes really important.

I love the idea of using var() instead of <dashed-ident> so we can compare any two expressions, not just variables. E.g. this opens the door for conditionals like 1em < 16px, so it's a strictly more powerful feature. We should probably define these comparison expressions separately, in Values & Units, and reference them in any other spec that needs them.

Indeed, not, or, and and operators should certainly be allowed, most likely with mandatory parentheses like in @supports.

@LeaVerou
Copy link
Member Author

LeaVerou commented Dec 11, 2020

A few issues with speccing generic comparison expressions: how are certain values interpreted outside of a declaration context? E.g. what are percentages relative to? If we disallow percentages, then I suppose we should define this as a union of specific types (and both sides of the comparison need to be of the same type), i.e. <dimension> | <color> | <image> .... There is currentcolor that works differently in color, and em that works differently in font-size. What do we do with them? Are there other values that are ambiguous outside of a declaration context? So far the only values we've allowed outside of declarations are those permitted in media features, which I think are limited to lengths.
Also, I suppose anything that is not a <number> or <dimension> would only work with equality (and inequality, if we define one, we could also just depend on not for that).
Is equality based on serialization of used values? E.g. is #f06 equal to #ff0066? What about to rgb(255 0 102)? They all serialize the same.

(this is mostly to @tabatkins but any input is welcome)

Edit: Oh, actually, if we define this as just sugar for inline if(), then we can use any value and it's interpreted in the context of each property inside the block. The equality question still stands.

@mildred
Copy link

mildred commented Dec 14, 2020

Hi,

I'm new here but I would like to give feedback on this proposal that seems a powerful way to achieve many things in CSS. I would also like to stress to participants that CSS improvements can be used in other areas than custom components, and those use cases should be considered as well.

There is a number of features that I find interesting in the @if proposal:

  • It is scoped inside selectors, and can use custom properties (unlike media queries which also allow conditionals, but only on media features)
  • it contains properties (unlike cond()) which allows to avoid defining a default value for the properties that are set
  • it can be used to implement native CSS mixins, which is what a lot of people were expecting from the @apply spec which is now abandonned.

For example, here is how I would use it for mixins:

* {
  @if(var(--mixin-danger) = on) {
    background-color: red;
  }
}

#my-error-message {
  --mixin-danger: on
}

As for naming, I would not use @if but rather @when because @if calls for a @else and @elseif which can complexify the syntax (elseif condition implicitly contains a negation of the conditions defined before, I'd prefer it to be explicit).

Also, would such condionals be allowed to be nested ?

Edit: Oh, actually, if we define this as just sugar for inline if(), then we can use any value and it's interpreted in the context of each property inside the block. The equality question still stands.

This would bring surprising behaviour where a property in your conditional could be applied but not the next one because the conditional applies differently to it.

I think the sanest approach would be to compare string equality and only allow comparison on types that are independent of the context. If you define --color1: #f00 and --color2: red, then @if var(--color1) = var(--color2) would be false. If we want to be able to compare colors, then perhaps we can have a function that would take a color expression of any form and return a standardized value that can then be compared.

If you allow colors to be compared, then you'll get a problem when a custom property contains something that can be interpreted as a color but is not meant to be a color. it could have an equality match with a value where this is not expected.

@LeaVerou
Copy link
Member Author

LeaVerou commented Dec 14, 2020

@Que-tin

@tabatkins How will nested conditions be written?

I saw this example of yours in the linked issue of course I think commas make more sense here.

margin-left: cond((50vw < 400px) 2em, (50vw < 800px) 1em, 0px);

But what if I turn the cond around? This makes it quite unreadable in my opinion especially the comma-separated list of possible values. Imagine having more than two conditions nested in each other

margin-left: cond((50vw < 400px) (50vw < 800px) 1em, 0px, 2em);

Or will it be possible to do smth like this to increase readability? Should be, correct?

margin-left: cond((50vw < 400px) ((50vw < 800px) 1em, 0px), 2em);

I'm not @tabatkins but hopefully your question is directed to the group and not specifically towards Tab?

Parenthesizing the conditional like that makes for a very hard to read syntax any way you order these.
I would argue that a comma-separated three argument function is the way to go:

margin-left: if(50vw < 400px, 2em, if(50vw < 800px, 1em, 0px));

which I believe is more readable than any of the examples above, and more externally consistent (this is how inline conditionals work in CSS preprocessors (Sass Less), as well as in spreadsheets).

But how about the at-rule?

my-input {
  border-radius: 0;
  @if (var(--pill) = on) {
    @if (var(--half) = on) {
      border-radius: 10px;
    }
    border-radius: 999px
   }
}

Nested @if rules definitely need to be allowed and desugar via inline conditionals using and. However, your example raises an interesting point: border-radius: 999px comes after the @if (var(--half) = on), so it would be reasonable to override border-radius regardless of the value of var(--half) and desugar to:

my-input {
	border-radius: if(var(--pill) = on, 999px, 0);
}

I think what you meant to write was perhaps this:

my-input {
   border-radius: 0;
   @if (var(--pill) = on) {
     border-radius: 999px;
     @if (var(--half) = on) {
       border-radius: 10px;
     }
    }
}

which would desugar to:

my-input {
	border-radius: if(var(--pill) = on, if(var(--half) = on, 10px, 999px), 0);
}

@LeaVerou
Copy link
Member Author

LeaVerou commented Dec 14, 2020

@mildred: Mixins was one of the use cases I mentioned in the original proposal, however do note that implementing these as sugar on the if() + the IACVT behavior both limit their utility for those use cases quite severely. Namely, in your example, background-color would be set on every element, either to red, or to unset. It would be essentially equivalent to this:

* {
  background-color: if (var(--mixin-danger) = on), red, unset);
}

#my-error-message {
  --mixin-danger: on;
}

This means that if you have something like this:

* {
  @if(var(--mixin-danger) = on) {
    background-color: red;
  }
}

div {
  background-color: yellow;
}

#my-error-message {
  --mixin-danger: on
}

and a <div id="my-error-message">, it wouldn't be red, but yellow, because background-color does not contain a conditional anymore. However, even if @if worked differently and actually cascaded, background-color: yellow would still override it. Basically, you'd need to stop using non-custom properties outside of @if rules to get these to behave as mixins.

Unfortunately, the feedback we got from implementers is that if we make the rule cascade, it is much harder to implement. I'm unclear on whether there are any constraints that would make cascading conditionals implementable (either limitations in what they contain, or in the condition itself). The question is, if they do not cascade (outside the rule they are defined in), do they still solve a large number of use cases?

@LeaVerou
Copy link
Member Author

LeaVerou commented Dec 16, 2020

Trying to write up an Unofficial Draft on this, I've come across a few issues. @tabatkins and I had a good discussion yesterday about them. I'm going to try and summarize the current status here.

How to implement @if

Pseudo-classes are out of the question, since they match at a completely different point and would cause architectural issues otherwise.
If we make @if cascade, we'd need to carry invisible extra context with each property, which is a significant increase in complexity across every declaration, whether it's inside a conditional or not.

It looks like the best tradeoff of implementation convenience and use case coverage is to implement @if based on my original idea of desugaring it into inline if() calls, but also taking into account any properties defined within the same rule as the @if block. E.g. this:

.button {
	border-radius: 2px;
	@if (var(--pill) = on) {
		border-radius: 999px;
		padding: 0 1em;
	}
}

desugars to:

.button {
	border-radius: if(var(--pill) = on, 999px, 2px);
	padding: if(var(--pill) = on, 0 1em, unset);
}

Avoiding partial application

There are certain values in CSS that evaluate differently depending on which property they are specified on. The obvious one is percentages, but also em, rem, lh, rlh, currentColor.

Currently, for a generic inline if() function, it makes sense to evaluate the condition in the context of the property it's specified on. However, this means that if we desugar @if by using if() on each value for each declaration it contains, any relative values used may make the condition true for some declarations and false for others. E.g. consider this:

.foo {
	@if (1em > 5%) {
		width: 400px;
		height: 300px;
	}
}

which desugars to:

.foo {
	width: if(1em > 5%, 400px);
	height: if(1em > 5%, 300px);
}

Now consider that an element that matches .foo is inside a 600px by 400px container and has a computed font-size of 25px; This makes 1em > 5% evaluate to false on the width property and true on the height property, which would make the @if partially applied. We most definitely don't want that.

One solution we came up with was to define two kinds of inline conditional functions: One that works as described above, and is not used for desugaring @if, and one whose main purpose is for desugaring @if, let's call it property-agnostic-if() (name obviously TBB) for the purposes of this discussion. That function will evaluate each type within its condition against a predefined property, e.g. color for <color> values, width for <length> or <percentage>, font-size for em and so on. This means it will evaluate the same on any property, preventing partial application for @if blocks that do not contain nested rules.

However, once CSS Nesting comes into play, partial application becomes a problem again. Many use cases require the @if to control multiple rules, which would become possible with nesting. However, since condition evaluation depends on the element context, this could mean the conditional matches for the rule it's defined on and doesn't match for nested rules or vice versa! For example:

.tabs {
	display: grid;

	@if (var(--alignment) = top) {
		grid-template-rows: auto 1fr;

		& > .tab-strip {
			display: flex;
		}
	}
}

What happens if .tabs has --alignment: top and .tab-strip has --alignment: left? So far we have not found a solution to this. Ideally, we'd want matching to happen at the root rule and everything inside the @if block either applies or doesn't, but there doesn't seem to be any reasonable way to desugar that into inline functions. Should we just live with this and caution authors against it? Do note that for the Web Components use cases, a lot of this matching will be in Shadow DOM, which is controlled by the component author. So maybe it's ok? However, once the feature is out, authors will use it in a more general way, and nothing can put that genie back in the bottle.

Cascading

Inline conditionals will have the IACVT (Invalid At Computed Value Time) behavior that we have come to know and love (?) from Custom Properties. Since @if will desugar to inline conditionals, it will also fall back to that, which may sometimes be surprising. This means that these two snippets are not equivalent:

.notice {
	background: palegoldenrod;
}

.notice {
	/* Desugars to background: if(var(--warning) = on, orange, unset); */
	@if (var(--warning) = on) {
		background: orange;
	}
}
.notice {
	/* Desugars to background: if(var(--warning) = on, orange, palegoldenrod); */
	background: palegoldenrod;

	@if (var(--warning) = on) {
		background: orange;
	}
}

This also affects how CSS optimizers combine rules, since combining rules with identical selectors can now produce different effects.

There is also the example in my comment above, where even though it makes sense if you think about it, for some reason the result feels very surprising.

@css-meeting-bot
Copy link
Member

The CSS Working Group just discussed [css-variables?] Higher level custom properties that control multiple declarations.

The full IRC log of that discussion <dael> Topic: [css-variables?] Higher level custom properties that control multiple declarations
<jensimmons> Does that mean we get a CSS 2020 in 2020??
<astearns> just barely
<dael> github: https://github.com//issues/5624
<dael> leaverou: I didn't explicitly add this. WE discussed last time and didn't get resolution. Interesting discussion in issue and off GH
<leaverou> https://github.com//issues/5624#issuecomment-746339609
<dael> leaverou: I summerized current state in ^ comment
<dael> leaverou: Summary: It looks like best course of action for block conditionals. Can't use pseudo class, casuse issues. If if() cascades have to carry extra context and increases too much complexity
<dael> leaverou: Best is impl if based on idea of desugering to inline if calls and take into account properties in same rule. example in comment
<dael> leaverou: Rasises some issues b/c certain values eval differently depending on prop. Hasn't come up that much. Length in some MQs
<dael> leaverou: For example, ones we could come up with TabAtkins is %, em values, rem, lh, rlh, currentColor.
<chris> rrsagent, here
<RRSAgent> See https://www.w3.org/2020/12/16-css-irc#T17-34-13-1
<dael> leaverou: Problem. If it desugars to inline if calls nad conditional has relative values you may have cases where part of rule eval to true and a part of false. Example in comment.
<dael> leaverou: Agreed don't want partial applicaitons. How to solve?
<dael> leaverou: Came up with defining how these relative values would be evaluated. cureentColor is as if in color and so on. New inline conditional function to desugar iff
<dael> leaverou: Doesn't sound good, but couldn't come with better
<dael> leaverou: Addresses single conditional. Css nesting has same partial applicaiton problme. May have condition true for a rule but not decendnents.
<Rossen_> q?
<dael> leaverou: Might have var warning = on and a value for --warning on parent and different value on the child
<dael> leaverou: You again have @if block applied paritially
<dael> leaverou: Not sure if there's a way to address this. Couldn't come up with anything but just discussed yesterday. Don't know if there are ideas
<dael> fantasai: What do you do if content has if clause with a property that effect evaluation. if on a em and evaluate em against font size
<dael> leaverou: Can you put example in IRC?
<fantasai> @if (var(...) > 1em) { font-size: 35pt; }
<dael> leaverou: I see
<dael> leaverou: I'm not sure
<dael> leaverou: What would you suggest should happen?
<dael> leaverou: It's basically same as if you have inline if
<dael> Rossen_: In interest of time, are we ready to resolve or should we take it back to GH and continue there?
<dael> leaverou: I suppose we could go back to issue
<dael> Rossen_: Let's do that. Let's continue discussing there. I was hoping we were closer to resolution then we are. We'll come back

@dead-claudia
Copy link

dead-claudia commented Dec 26, 2020

@LeaVerou Chances of that if going anywhere in that particular form is slim to none due to reasons explained by @tabatkins in #3455 as he rejected that issue.

Edit: also read up on CSS mixins and the surrounding proposals related to that. The other half of this is basically that.

@andruud
Copy link
Member

andruud commented May 26, 2021

What about having the computed variables of a container being one of the things that can be queried by container queries?

#component {
  /* Approximately the current state of bikeshed on how to mark a container,
     if I understand Issue 6174 correctly. */
  query: custom-properties;
}

@container (--pill=on) {
   .box { ... }
}

This doesn't really add any significant complexity*, since knowing the computed style (and even layout) of the container before evaluating the container query is already needed for normal (size) queries.

We'd then avoid Emilio's concern with Element.matches.

cc @mirisuzanne

* I think. Famous last words.

@LeaVerou
Copy link
Member Author

Interesting. Would we be able to style #component itself based on --pill or only descendants?

@andruud
Copy link
Member

andruud commented May 27, 2021

Only descendants unfortunately, otherwise we get circularity problems.

@LeaVerou
Copy link
Member Author

It's not ideal, but combined with an inline if() (that triggers IACVT) it could work.
Shadow DOM elements would be able to style themselves based on properties on :host, right?

@andruud
Copy link
Member

andruud commented May 27, 2021

I don't think container-queries+ShadowDOM have been thought about thoroughly yet, but yes that sounds like what we'd want.

@LeaVerou
Copy link
Member Author

That's the major use case, so whatever solution we pick needs to be able to work with that.

@LeaVerou
Copy link
Member Author

LeaVerou commented May 27, 2021

Is the @container solution able to do other kinds of conditionals too? E.g. 1vw > 1em? Or only one custom property compared with one value? It would be nice if people can compare values of entire expressions instead of having to basically solve for x to find what the bounds are for the custom property, but that's a nice-to-have, not a dealbreaker. That would also allow them to have conditionals that depend on multiple custom properties.

@andruud
Copy link
Member

andruud commented May 27, 2021

That should be possible, provided that we can define what the operators mean and how things should evaluate. For example, in 1vw > 1em both sides might resolve to pixels, but for --x > 1em the left hand side resolves to a token sequence, the right hand side to pixels, so we'd need rules for how all of that should work. (In this case maybe @property is needed to make --x > 1em allowed)?

It would be nice if people can compare values of entire expressions

Mostly a matter of specifying the grammar + rules I think. Totally possible.

@LeaVerou
Copy link
Member Author

LeaVerou commented May 27, 2021

Sure, but a lot of these rules are needed for if() anyway. The extra complexity of the @-rule is that they need to resolve in a way that is property-independent.

@muratcorlu
Copy link

muratcorlu commented May 13, 2022

I just noticed this discussion after @johannesodland mentioned here in my proposal (#7273) for addressing the same issues in a different way. I completely agree about the needs we have here as @LeaVerou explained.

Do you find useful, a solution like the below by having map-get function (as in older versions of SASS) to address the issues mentioned here?

<my-button>Save</my-button>
my-button {
   --size: small;
}

@media screen and (max-width: 900px) {
  my-button {
    --size: large;
  }
}
/* inside my-button component style */
:host {
   padding: map-get((small: 4px  8px, regular: 8px 16px, large: 12px 24px), var(--size, regular));
}

More details are in #7273.

@Crissov
Copy link
Contributor

Crissov commented Feb 13, 2023

With the proposal in #3714 slightly extended for conditionals, a solution could look something like this:

$pill {
  border-radius: 999px;
}

my-input {
  @include pill if (var(--pill) = "on");
}

@trusktr
Copy link

trusktr commented Jul 14, 2024

This can be implemented in CSS today using cyclic space toggles (I like to think of them like enums and switch cases).

.foo {
  --size: var(--small);
  --theme: var(--dark);
  --placement: var(--top);
  --orientation: var(--vertical);
  --significance: var(--warning);
}

Although it isn't the most convenient, here's how something like --size can be implemented as a library author:

/**
 * ## CSS library author code: ################################
 */
button {
	/* define the configurable option for the end user, with its default value */
	--size: var(--size-medium);

	/* define all the possible enum values for the option (commas are required, they enable the magic) */
	--size-small: var(--size,);
	--size-medium: var(--size,);
	--size-large: var(--size,);
	
	/* finally apply output values using a switch case inside of each output property */

	border:
		solid deeppink
		var(--size-small, 1px)
		var(--size-medium, 2px)
		var(--size-large, 3px);

	border-radius:
		var(--size-small, 2px)
		var(--size-medium, 4px)
		var(--size-large, 6px);

	padding:
		var(--size-small, 2px)
		var(--size-medium, 4px)
		var(--size-large, 6px);
}

Here's how an end user would use it:

<button id="one">button</button>
<button id="two">button</button>
<button id="three">button</button>
/**
 * ## End user code: ##########################################
 */
#one {
	/* end user picks the enum value they want */
	--size: var(--size-small);
}

#two {
	--size: var(--size-medium);
}

#three {
	--size: var(--size-large);
}

where, depending on the picked --size, the component library will set a bunch of properties such as border-width, padding, margin, height, to various different values depending on the single value of --size, where --size is not just a length value.

codepen example: CSS enums and switch-case logic

Roman Komarov has some nice articles on it here: https://kizu.dev/layered-toggles/

TLDR: yeah, maybe not entirely intuitive. But! Once the pattern is learned, it is easy to implement in libraries, and most importantly super easy for the end user to use.


Using one of the syntax ideas above, here's the same thing in would-be new format:

button {
	--size: medium; /* possible values: small, medium, large */
	
	border:
		solid deeppink
		if(var(--size) = small, 1px)
		if(var(--size) = medium, 2px)
		if(var(--size) = large, 3px);

	border-radius:
		if(var(--size) = small, 2px)
		if(var(--size) = medium, 4px)
		if(var(--size) = large, 6px);

	padding:
		if(var(--size) = small, 2px)
		if(var(--size) = medium, 4px)
		if(var(--size) = large, 6px);
}

#one {
	--size: small;
}

It is not a ton more terse, but the main advantage is that there is no cognitive overhead needed as with the space toggles. Someone who sees this code will know how it works without having to learn how space toggles and property cycles work.

Paired with @property, IDEs could support type checking:

@property --size {
    syntax: "small | medium | large";
    inherits: true;
    initial-value: medium;
}

(namespaced property names highly recommended)

@LeaVerou
Copy link
Member Author

I’m well aware of cyclic toggles, but they are a hack/workaround, not something we can be content about as a solution and have quite a lot of limitations, not to mention the awkwardness of actual values having to be hidden behind variables.
@kizu who came up with them is actually an Invited Expert in the group and has exactly the same opinion — in fact we used the ugliness of this and other hacks as an argument to convince the WG to resolve for if() in #10064

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests