Re: [csswg-drafts] Proposal: CSS Variable Groups (as a solution to several design systems pain points) (#9992)

Hi everyone! Wow, lots of comments. 
I also added an alternative, less ambitious design, that could ship earlier as it is three independent features. 

Some overall comments:
- Some people mentioned making this part of `@property`. The issue is that `@property` is tree-scoped and cannot be scoped to a specific subtree, whereas it is important for use cases to be able to e.g. set `--color-primary` to a different group for a different subtree (without having to turn it into a component).
- Some people mentioned making the default part of the definition of `--color-green` with the braces coming at the end:
```css
--color-green: oklch(65% 50% 135) {
  100: oklch(95% 13% 135);
  200: oklch(95% 15% 135);
  /* ... */
  900: oklch(25% 20% 135);
};
```
However, one of the concessions we had to make to make `&`-less CSS nesting possible was that we'd never use braces in a property value *unless* the *entire* property value was enclosed in braces. So discussing the merits of that syntax is moot, as it's not feasible with the current constraints.
- Some people mentioned that they didn’t like the implicit `-` separator, and they would prefer a syntax that makes it explicit, such as:
```css
--color-green:  oklch(65% 50% 135);
--color-green-*: {
  100: oklch(95% 13% 135);
  200: oklch(95% 15% 135);
  /* ... */
  900: oklch(25% 20% 135);
};
```
Similarly, some folks mentioned they’d like a more explicit syntax that makes it clearer that multiple properties are being set, e.g. by setting `--prefix-*` instead of just `--prefix` and separating the base into a separate property. 
My main reservation with that is that this allows them to get out of sync, so that you end up having e.g. `--color-green-*` that uses a different shade of green than your `--color-green`.
Also, it means you cannot style an aspect of a component by passing a single variable to it that contains all the color information it needs.
- Some folks (e.g. @kizu) mentioned they’d prefer a different syntax for getting arbitrary variants than just turning these into functions so they don’t clash with actual functions. I tend to agree that this makes sense, and I liked @kizu’s `get()` idea for that.

-----
Specific replies:

@romainmenke 
<details><summary>I like this and I wonder if it can also solve length values?</summary>
<p>

> In design systems it is really common to also have a bunch of length values for margin, padding, font-sizes,...
> 
> Design tools only offer the ability to export everything as pixels or as rem. But CSS authors need both. Sometimes `rem` is the right unit, sometimes `px`, to ensure that the design scales correctly for end users.
> 
> So we end up writing this mess :
> 
> ```css
> :root {
>  --space-1--px: 1px;
>  --space-1--rem: 0.0625rem;
>  --space-2--px: 2px;
>  --space-2--rem: 0.125rem;
>  --space-4--px: 4px;
>  --space-4--rem: 0.25rem;
>  --space-6--px: 6px;
>  --space-6--rem: 0.375rem;
>  --space-8--px: 8px;
>  --space-8--rem: 0.5rem;
>  --space-15--px: 15px;
>  --space-15--rem: 0.9375rem;
>  --space-16--px: 16px;
>  --space-16--rem: 1rem;
>  --space-18--px: 18px;
>  --space-18--rem: 1.125rem;
>         /* goes al the way to 96 ... */
> }
> ```
> 
> Maybe there is something to [custom units](https://github.com/w3c/csswg-drafts/issues/7379) which are also a kind of custom prop?

</p>
</details> 

Absolutely! I used colors as an example just because it's the most complex part of most design systems, but everything is meant to apply to entire design systems.
However, with the current proposal it would require a different naming scheme where px/rem comes first:

```css
--space: {
 px: {
  default: calc(1px * arg);
 };
 rem: {
  default: calc(arg / 16 * 1rem);
 }
}
```

If anything, this use case highlights how important it is to be able to get programmatically defined tokens!
Not only is this much more compact (as @jens-struct mentioned, the size of these CSS files is a huge problem), but the intent is also much clearer and does not have to be inferred. And if more granularity is desired, it's trivial to get it.

@adamwathan 
<details><summary>Dynamic utility classes</summary>
<p>

> If something like this makes it into CSS I would _love_ a way to reference these groups in other potential future CSS features to sort of create dynamic selectors.
> 
> Tailwind for instance generates utility classes for every color automatically, but all of the tooling to do it is incredibly complex and relies on scanning all of your HTML files to find classes you're using and generate them.
> 
> It would be amazing to have some way to declaratively define a matching pattern in CSS that points to a group and makes these selectors "just work".
> 
> This is terribly under-thought pseudo-code but maybe it's enough to communicate the idea:
> 
> ```css
> :root {
>   --color-green: {
>   100: oklch(95% 13% 135);
>   200: oklch(95% 15% 135);
>   /* ... */
>   900: oklch(25% 20% 135);
>   }
> }
> 
> @match .bg-${color} {
>   background-color: group(--color, match(color));
> }
> ```
> 
> This would allow me to use classes like `bg-green-500` in my HTML without actually defining every single class.
> 
> This is obviously way out of scope for this proposal but it's the sort of interesting future use case that might be worth considering during the design. This sort of thing could be a Tailwind killer in a very good way 😄
> 
> We're prototyping this style of syntax for Tailwind CSS v4 as a way to create custom utilities that we ingest and process as a preprocessor, but it would be amazing to get power like this in the language in some way one day.

</p>
</details> 

This is not just Tailwind, the FontAwesome folks were recently telling me about this *exact* same problem. They would love to be able to have `--fa-something: "..."` variables and have the `.fa-something` class automatically apply that declaration without having to spit out thousands of mappings.

But even beyond these types of use cases, there are tons of use cases where you’re basically using class names with a common prefix as essentially key-value pairs, and need the equivalent of attribute selectors and `attr()` for these key-value pairs.

I think this could actually be a separate feature to let you get the part after the prefix and target class names by prefix, and then together with this proposal you can implement the mapping. Something like this:

```css
[class~^="bg-"] {
 background: get(--color, class-suffix(bg-*));
}
```

<details><summary>WRT base values</summary>
<p>

> But what are the contexts which would support this magic key but not groups?
> 
> I've built from designs that used "default" as a color level, and have been glad to have that word available.
> 
> This feels to me more radical (new "magic key" pattern) and restrictive (can't use that word in a single-word nests) than it's worth to make this
> 
> ```css
> --color-green: {
>   base: oklch(65% 50% 135);
> }
> ```
> 
> create `--color-green` rather than `--color-green-base`.
> 
> Removing the magic eliminates the question "how do we override just the default value?":
> 
> ```css
> --color-green: {
>   base: oklch(65% 50% 135); /* styles --color-green-base */
> }
> --color-green-base: oklch(65% 50% 130);
> ```
> 
> would work to make core green a little yellower, with no special case needed.
> 
> Share more about the motivation?
> 
> >

</p>
</details> 

The main motivation is twofold:
1. To ensure that groups produce a value when used in regular properties
2. Communication: In many design systems it's unclear what the "base" value is.

I’d love to hear more about the cases where you want to have a value called "base" or "default", but it's not actually the same value as you want the property to return when no suffix is used.

<details><summary>One thing I haven't seen mentioned yet (but maybe everyone just is aware/understands and sees no issue) is that the proposed syntax is already valid:</summary>
<p>

> 
> <img alt="image" width="1042" src="https://private-user-images.githubusercontent.com/4323180/307530630-3df22ce3-821d-4245-81b6-9fb6ba8e831f.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MDg4MDUwMjYsIm5iZiI6MTcwODgwNDcyNiwicGF0aCI6Ii80MzIzMTgwLzMwNzUzMDYzMC0zZGYyMmNlMy04MjFkLTQyNDUtODFiNi05ZmI2YmE4ZTgzMWYucG5nP1gtQW16LUFsZ29yaXRobT1BV1M0LUhNQUMtU0hBMjU2JlgtQW16LUNyZWRlbnRpYWw9QUtJQVZDT0RZTFNBNTNQUUs0WkElMkYyMDI0MDIyNCUyRnVzLWVhc3QtMSUyRnMzJTJGYXdzNF9yZXF1ZXN0JlgtQW16LURhdGU9MjAyNDAyMjRUMTk1ODQ2WiZYLUFtei1FeHBpcmVzPTMwMCZYLUFtei1TaWduYXR1cmU9YzQ0YWZkNjVjZDBjNGViOGJmNDYzZjkxNDkyYjUwNzQxNjRkOGYyOTgxMmQwNGRiZjA5YmU2NGFiOTUxYzJjOCZYLUFtei1TaWduZWRIZWFkZXJzPWhvc3QmYWN0b3JfaWQ9MCZrZXlfaWQ9MCZyZXBvX2lkPTAifQ.adiklwfaZy8emN3bHm1BxQVh4Qd22b9CUlNAdkuUM5Q">
> ...which is good in some ways (no parsing changes) but also means this would be adding new behavior to any projects already storing JSON-y type stuff in a custom property, which as I understand was an intentional design goal of custom properties from the beginning, to allow storing things you might pull out using JS.
> 
> Any projects already doing anything like this would now implicitly have new variables defined in their project that they didn't have before. Maybe not an issue in practice, but thought it was worth making sure everyone is aware.

</p>
</details> 

This came up in the nesting discussions, and we did the research then, it turns out there were exceedingly few websites actually doing this.

> In what way is the result different from removing the first three lines?

Then `--color-green` is not a group, so setting `--color-primary` to `var(--color-green)` does not also port any other properties.

@kizu 
> What I'm not sure about is the syntax: I am not sure that promoting the variable groups to functions/mixins is a good idea, as it makes it easy to clash with the regular functions, and makes the double-dashed functional syntax tightly coupled with the variables themselves.

Yeah, that’s one of my concerns as well. 

> If I proposed something, I'd try something like a `get()` function, which will accept the group's name as the first argument, and the list of arguments to get as the following ones, so instead of `--color-primary(dark, 75)` we could do `get(--color-primary, dark, 75)`. Bonus point will be if _every_ argument could accept a CSS variable: `get(var(--dict), var(--theme), var(--tint))`.

I love this idea!
It can even be entirely decoupled from this proposal and work before this ships, with any hyphenated names!

> Do we need some kind of a shorthand syntax for defining lists/arrays? So instead of `--list: { 0: foo; 1: bar; }` we could do something like `--list: [foo, bar]`?

> For the `default` fallback property a name like `fallback` could work better, as it might be easy to confuse “default” with “base” (and it was already proposed as an alternative: “alternative names: default, value”)/

"Fallback" implies that this is a value that is used when something goes wrong, which is not how I see it at all. While it is *also* used as a fallback that’s not really its primary purpose. I see of it a bit like `valueOf()` or `Symbol.toPrimitive` in JS.

> Do we need to have a way of merging several groups into one? Like destructuring two objects in JS?

Use cases?

@brandonmcconnell 

>  By groups be infinitely nestable, does that mean that we could groups in groups in groups, etc., and essentially see `primary-100-200-400` (all together like that) variants come into play (practicality aside)?

Yes.

> Have you considered alternate syntax options like that? What pros and cons do you see, and what led you to your eventual conclusion? I'd love to understand the journey you went through to get to this final product.

As I replied to @kizu above, I think a functional syntax that uses a separate function is the way to go when arbitrary keys are desired. However, it should not be the *only* way because these tokens are used all over the place, so even a little added verbosity adds a lot of friction. Also, there are multiple components to this problem, and referencing arbitrary variants is not the top pain point.

<details><summary>naming collisions</summary>

If I am in this situation:
  
  ```postcss
  :root {
   --color-primary-100: black;
   --color-green: {
    100: oklch(95% 13% 135);
    200: oklch(95% 15% 135);
    /* ... */
    900: oklch(25% 20% 135);
   };
  }
  
  some-component {
   --color-primary: var(--color-green);
  }
  ```
  
  I'm not sure how clear it is that by—essentially—spreading those grouped values into the new `--color-primary` variable, you are also overriding the value(s) of any variables that happen to be named the same name as that variable plus one of its properties/nested values (`{custom-property-name}-{nested-property-name}`)

</details> 

Are there any use cases where this is a bug, not a feature?
Suppose you set your primary color from green to blue.
What if your old primary color had 20 variants and the new one has 10?
Would you want `--color-primary-50` to still be a light green?

@jens-struct 
> When you do the semantic aliasing through css vars, you can't mark the not semantic aliased css vars as private. So in a context of a widly distributed design system, you can't make sure that the design tokens are used in the correct way.

I think that’s a separate issue, but also I disagree that semantic tokens are the only true way and the rest of the color palette should be hidden. That just ends up adding bloat to the design system as an attempt to cater to reality. But as you say, this is off-topic.

> Another much bigger problem, at least from a performance perspective, is the size of the css files

Yes! That is definitely a very explicit goal of this proposal.

@Crissov 
> Alas, everything I can think up allows arbitrary variants, e.g. 0% through 100% for some parameter, which goes against the philosophy of (atomic) design systems which try to increase maintainability by limiting the implementation choices for authors.

Yeah, I was thinking of this too; there are certainly use cases where you want the definition to be continuous for author convenience, but the tokens exposed to still be discrete (with a way to easily change the range and granularity). Perhaps `default-*` longhands could help there, e.g. `default-range` or something.

@MaxArt2501 
> A question I have is that it's not clear how to get the root property name from a specific one, i.e. from `--color-primary-dark-75` to `--color-primary` with `dark` and `75` specifiers. (What that mean some kind of string manipulation in CSS?)

With the current proposal you cannot (just like in JS you can't get from `color.primary[75]` to `color`), you’d need to pass the broader scope around (i.e. `--color`), which can then be used either as a function (`--color(primary, 75)`) or as several properties.

@Afif13 
<details>
<summary>Will we have an implicit Group creation if we define variables that share the same prefixes?</summary>
<p>

> If for example I do the following:
> 
> ```css
> :root {
>   --color-red: red;
>   --color-blue: blue;
> }
> ```
> 
> Does it mean that, automatically, we have the group variable `--color` ?
</p>
</details> 

This is covered in the proposal. Not by default, you’d need to set `--color: {}` before setting these to convert them to a group.
With the decomposed proposal you can reference `var(--color-*)` without setting anything.

@GreLI 
> Could be this proposal unified with `@property` rule? So one can define not only properties but their configuration too.

The issue is that `@property` is tree-scoped and cannot be scoped to a specific subtree, whereas it is important for use cases to be able to e.g. set `--color-primary` to a different group for a different subtree (without having to turn it into a component).

-- 
GitHub Notification of comment by LeaVerou
Please view or discuss this issue at https://github.com/w3c/csswg-drafts/issues/9992#issuecomment-1962741329 using your GitHub account


-- 
Sent via github-notify-ml as configured in https://github.com/w3c/github-notify-ml-config

Received on Saturday, 24 February 2024 21:58:31 UTC