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

Proposal: CSS Variable Groups (as a solution to several design systems pain points) #9992

Open
LeaVerou opened this issue Feb 24, 2024 · 44 comments

Comments

@LeaVerou
Copy link
Member

LeaVerou commented Feb 24, 2024

The whole proposal is here, I have included a summary below.

Pain points (summary)

The main pain point is aliasing. Currently, design systems are specified as a long series of variables, e.g. --color-green-100, --color-crimson-950 etc.
Aliasing them to other names (e.g. semantic names such as --color-primary or simply shorter/simpler names) is currently author hostile, as it requires manually aliasing every single one, i.e. hundreds of declarations. Not only is this painful to do for the whole page, it makes it very hard to theme areas on the page with a different color, or pass design tokens to components.

Additionally, tints and shades for each color are generated manually, even if some of them could be inferred via interpolation. Theoretically that can already be done via a series of color-mix() values, but it is very tedious and repetitive, especially if you want to tweak the endpoints by adding more manual values where the interpolated ones don't work well.

Other ideas explored

The following are useful in their own right, but I don’t think solve the pain points equally well.

The proposal (summary)

At its core (and as an MVP) this proposal allows authors to define groups of variables with the same prefix by using braces, and then pass the whole group around to other variables.

/* Author CSS */
:root {
	--color-green: {
		100: oklch(95% 13% 135);
		200: oklch(95% 15% 135);
		/* ... */
		900: oklch(25% 20% 135);
	};
}

some-component {
	--color-primary: var(--color-green);
}
/* some-component.css */
:host {
	background: var(--color-primary-100);
	border: var(--color-primary-400);
	color: var(--color-primary-900);
}
  • Groups can be infinitely nested, to specify not only tints of a single color, but an entire color palette or even an entire design system and pass it around with a single CSS variable.
    The granularity is completely progressive: you can go from defining --ds-color-green as a group, to defining --ds-color as a group to defining --ds as a group without changing anything about the places referencing them.
  • Individual properties can still be specified outside of the group and overridden via regular CSS cascade rules. This provides a migration path for porting existing design systems to groups without their code having to be updated.
  • Groups (and their constituent properties) inherit normally, like any other value. Custom properties in descendants can shadow inherited properties with the same name, i.e. a local --color-green-100 will override the inherited one from --color-green (just like with shorthands).
  • There may be value in exposing a functional syntax for every CSS variable group, to facilitate selecting

Using groups on non-custom properties: The base property

Authors can optionally specify a base value, which will be output if the custom property is used in a context that does not support groups, such as a regular CSS property.

--color-green: {
	base: oklch(65% 50% 135);
	100: oklch(95% 13% 135);
	200: oklch(95% 15% 135);
	/* ... */
	900: oklch(25% 20% 135);
};

.note {
	/* Same as border: 1px solid oklch(65% 50% 135)); */
	border: 1px solid var(--color-green);
}

This can also be tweaked from outside the group (just like the group properties), by setting --color-green-base. This also facilitates the use case of having constituent properties that are dynamically computed from the base so changing the base tweaks all of them in unison.

Continuous tokens

(This is definitely beyond MVP and more speculative)

Authors can specify a catch-all expression to be used to resolve undefined keys via a default property:

--color-green: {
	base: oklch(65% 50% 135);
	100: oklch(95% 13% 135);
	default: color-mix(in oklch, var(--color-green-100) calc((100 - arg / 10) * 1%), var(--color-green-900));
	900: oklch(25% 20% 135);
};

Together with something like color-scale() this could also facilitate piecewise interpolation.

Alternative decomposed design

We could decouple this into three separate features:

  • A function to map CSS variables with a common prefix to a different prefix, e.g. --color-primary: group(--color-green) or even var() itself, with a * to mark the prefix: --color-primary-*: var(--color-green-*);, which would require handling base values manually (but based on the comments, that seems to be seen as a feature for some).
  • A nesting syntax for setting multiple variables with the same prefix at once. This would look just like the one above, but instead of specifying a group, it is just syntactic sugar for setting many variables at once.
  • Continuous variations would have to be specified as a separate feature, perhaps by setting --color-green-* or using a syntax to mark the key part like --color-green-[tint] (which would also allow for multiple arguments in the future). The syntax space here is likely quite restricted due to the restrictions we had to introduce to the grammar to make &-less nesting work.

I ran this by a couple design systems folks I know, and the response so far has been overwhelmingly "I NEED THIS YESTERDAY". While I’m pretty sure the design can use a lot of refinement and I’m not sure about the technical feasibility of some of these ideas, I’m really hoping we can prioritize solving this problem.

Note that beyond design systems, this would also address many (most?) of the use cases around maps that keep coming up (don't have time to track them down right now, but maybe someone else can).

Before commenting please take a look at the whole proposal in case the issue you’re about to point out has been addressed there!

@MaxArt2501
Copy link

This looks so nice! And yes, I need that not just yesterday, but 5 years ago.

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?)

I'm not sure if there could be much of a use case... Maybe if I want to use another variant with a different specifier, i.e. using --color-primary-darker-75 for another part of the component.

@Afif13
Copy link

Afif13 commented Feb 24, 2024

Will we have an implicit Group creation if we define variables that share the same prefixes?

If for example I do the following:

:root {
  --color-red: red;
  --color-blue: blue;
}

Does it mean that, automatically, we have the group variable --color ?

@GreLI
Copy link

GreLI commented Feb 24, 2024

Could be this proposal unified with @property rule? So one can define not only properties but their configuration too. Maybe define groups with something like @property-group syntax to avoid ambiguity?

@Afif13 I think property groups are here to handle custom properties managing complexity. Groups do not have any value for its own sake. Otherwise it would be mentioned in the proposal.

@Afif13
Copy link

Afif13 commented Feb 24, 2024

@Afif13 I think property groups are here to handle custom properties managing complexity. Groups do not have any value for its own sake. Otherwise it would be mentioned in the proposal.

They do based on the whole proposal as we can do something like

:root {
	--color-green: {
		100: oklch(95% 13% 135);
		900: oklch(25% 20% 135);
	}
}

my-component {
	--color-primary: var(--color-green);
}

So if I define --color-green-100 and --color-green-900 manually, does it mean we also have an implicit access to --color-green

@GreLI
Copy link

GreLI commented Feb 24, 2024

That's a good point. I don't think however, that it should be propagated to auto-magically created groups. Could be a source of funny bugs.

@romainmenke
Copy link
Member

romainmenke commented Feb 24, 2024

I like this and I wonder if it can also solve length values?

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 :

: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 which are also a kind of custom prop?

@olets
Copy link

olets commented Feb 24, 2024

Interesting stuff!

--color-green: {
  100: oklch(95% 13% 135);
  200: oklch(95% 15% 135);
  /* ... */
  900: oklch(25% 20% 135);
}

Then this is equivalent to creating --color-green-100, --color-green-200, etc. variables.

We may need to start with a hyphen (or two)

Did you consider following compound nesting syntax?

--color-green: {
  &-100: oklch(95% 13% 135);
  &-200: oklch(95% 15% 135);
  /* ... */
  &-900: oklch(25% 20% 135);
}

Pros: no new pattern of an implicit separator, eliminates initial-digit question. Con: more verbose, could imply that --color-green: { my-component: /* … */ } might mean something. IMO those pro > those cons.

With that change, the essence of this proposal would be to extend nest syntax beyond selectors to custom properties. That extension feels natural to me, and the doors it opens might be valuable if the future is heavier in custom property use.

you can set a base value via the special base property (alternative names: default, value) which defines a plain value for when the property is used in a context that does not support groups, such as any of the existing non-custom properties:

initial would a CSS-y name choice, e.g. --color-green: { initial: oklch(65% 50% 135); /* styles --color-green */}

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

--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?":

--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?

:root {
  --color-green: {};
}

my-component {
  --color-primary: var(--color-green);
  background: var(--color-primary-200);
}

/* design-system.css */
:root {
  --color-green-100: oklch(95% 13% 135);
  --color-green-200: oklch(95% 15% 135);
  /* ... */
  --color-green-900: oklch(25% 20% 135);
}

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

my-component {
  --color-primary: var(--color-green);
  background: var(--color-primary-200);
}

/* design-system.css */
:root {
  --color-green-100: oklch(95% 13% 135);
  --color-green-200: oklch(95% 15% 135);
  /* ... */
  --color-green-900: oklch(25% 20% 135);
}

@kizu
Copy link
Member

kizu commented Feb 24, 2024

I really like this, and the authors wanted to have an ability to have arrays, lists, and maps in CSS for years! This proposal sounds good enough to cover most of their use cases on the first glance.

@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?)

If I understood the proposal correctly, the part that covers this is the functional syntax:

If we automatically exposed a function for every group, we could select the right token on the fly, possibly as a result of calculations. Nested groups would create functions with more than one argument.

With it, we could potentially do

.note {
    color: --color-primary(dark, 75);
}

And get the value of --color-primary-dark-75.

I 100% sure we need some way to dynamically extract values, and, especially, use dynamic variables to do so, like --color-primary(var(--theme), var(--tint)) or something. This could unlock many doors, and could make the variable groups and any related CSS features like mixins and functions tremendously powerful.

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.

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)).


Some other quick unsorted notes after reading the proposal:

  • 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”)/
  • Do we need to have a way of merging several groups into one? Like destructuring two objects in JS?

I'll need to spend more time thinking about this proposal, but I just want to reiterate how excited I am about it.

@brandonmcconnell
Copy link

brandonmcconnell commented Feb 24, 2024

I'm really digging this idea!

I had a few questions, and maybe some possible gotchas—

  • nesting clarification

    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)?

  • property reference syntax

    I know the idea of grouped properties has been tossed around before, usually using a separate function to access those values, e.g., groupVar(--color-primary, 100) (not trying to sell that name), with this general syntax: groupVar(<custom-property-name>, <nested-property-name>, <declaration-value>)

    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.

    Edit: I read @kizu's comment, and I think he did a better job expressing this point than I did

    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)).

  • naming collisions

    If I am in this situation:

    :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})

Thanks! 🙂

@jens-struct
Copy link

jens-struct commented Feb 24, 2024

I would like to contribute a critical perspective to the whole design-system aliasing topic in css vars. Sorry if this is a bit off topic. In my experience this causes more problems than it solves, and may encourage dark patterns, at least at the moment. For example:

  1. 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.

  2. Another much bigger problem, at least from a performance perspective, is the size of the css files, if you do semantic aliasing through css vars. As part of a bigger performance optimisation strategy, we switched a few design systems partially back from css vars to a server side abstraction like sass, were possible. (Keeping the semantic color css vars for dark theme support, not the color aliasing). This resultet in significant smaller css files and a much faster first paint. In one project we reduced the size by more then 50%, by moving stuff (several hundred var definitions) you don't neccessarily need in the browser, back to the server.

I know each design system is different, and there maybe usecases, where it makes sense to do more in the browser. But i don't think the general advice to do everything in the browser now, should come without a disclaimer about the disadvantages and that server side css tooling will never go away completely, at least for large projects.

In this regard, this proposal seems like it will also help reducing the file size aliasing can contribute to. (But i would still advice anyone not to do it completely in the browser, if not neccessary) I think a good combination of both is they key here, depending on the usecase. So the proposals that focus on extending css with design system focussed capabilities, are very welcome (like this and css functions)! Don't get me wrong. Just trying to support with input, on what can go wrong.

@ollicle
Copy link

ollicle commented Feb 24, 2024

My initial response to the green example of group assignment in isolation --color-primary: var(--color-green); was discomfort that there is no immediate way to discern a group assignment is occurring. It wouldn’t be quite so beautifully seamless, but vars() in place of var() to do this magic would help express what’s happening.

Of course there is always room for naming convention to suggest the “groupness” of a variable. For example, capitalising group names --color-primary: var(--Color-green).

I very much like how the standalone default concept levers the power offered by color-mix in your example. 👏

@adamwathan
Copy link

adamwathan commented Feb 24, 2024

Excited by the idea of somehow handling this in CSS, thanks for pushing it forward!

Bunch of thoughts, broken up by some horizontal rules...


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:

image

...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.


Personally I think the default, min, max, min-key, max-key, and arg stuff feels like too much scope to try and settle on for an initial version (as alluded to in the proposal).

A lot of it feels very specific to solving problems just for colors, and the idea of custom property groups is not really color-specific, so I think these will be hard and slow questions to come to decisions on.


I think the base idea is interesting (we did a very similar thing in Tailwind but it was this all caps DEFAULT keyword) but I don't like that it makes base a reserved word that can't be used as part of one of these custom property names within a group now. To me it doesn't feel worth it — I'd rather just not have a way to create a no-suffix variable within a group, or come up with some syntax that is totally conflict free, for example:

--color-green: oklch(65% 50% 135) {
  100: oklch(95% 13% 135);
  200: oklch(95% 15% 135);
  /* ... */
  900: oklch(25% 20% 135);
};

Challenge with that syntax of course is how do you override just the --color-green base variable without overriding the whole list, but honestly maybe it's fine to just have a limitation that you can't use this syntax if you want to do that. People can always fall-back to defining the custom properties one by one.

Another idea is to change the left hand side, for example:

--color-green:  oklch(65% 50% 135);
--color-green-*: {
  100: oklch(95% 13% 135);
  200: oklch(95% 15% 135);
  /* ... */
  900: oklch(25% 20% 135);
};

Now the implicit - separator is explicit and captured on the left


Agree with some others that the implicit - separator feels a bit magical — I think some way of including the dash in the nested token names feels safer to me, like what was mentioned in the proposal:

--color-green: {
	-100: oklch(...);
	-200: oklch(...);
	/* ... */
}

I think moving that to the left hand side with some sort of special character to imply the group would be nicer, like I mentioned above:

--color-green-*: {
  100: oklch(95% 13% 135);
  200: oklch(95% 15% 135);
  /* ... */
  900: oklch(25% 20% 135);
};

I like that you can disable a whole set of colors this way using initial:

@import "a-lot-of-colors.css";

:root {
  --color-potato: initial; /* Would disable the whole color group */
}

This is a problem I've been trying to figure out for Tailwind CSS v4.0 because we are moving all of our color token definitions to CSS instead of the JS config file we've used historically.

This would also work with the * syntax I suggested:

@import "a-lot-of-colors.css";

:root {
  --color-potato-*: initial; /* Would disable the whole color group */
}

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:

: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.

@Crissov
Copy link
Contributor

Crissov commented Feb 24, 2024

So, you want custom longhand properties with systematic names based on their shorthand. I think you should make clearer that whatever syntax this ends up using could likely also be backported to normal properties.

Could it be possible to drop base and put the default before the braces?

:root {
--color-green: 
  oklch(65% 50% 135deg) {
    &-100: oklch(95% 13% 135deg);
    /* … */
    &-900: oklch(25% 20% 135deg);
  };
  font: serif {
    &-size: 2em;
    line-height: 3em;
  };
}

Design tokens and design systems are about a lot more than color, but I’ll focus on color here, as that is the worst of it and also easier to explain.

Still, it makes me wonder whether this could be solved even better in a way specific to colors, because this reminds me a bit of CNS (with custom base values). 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.

@argyleink
Copy link
Contributor

cc #9206

@LeaVerou
Copy link
Member Author

LeaVerou commented Feb 24, 2024

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:
--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:
--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

I like this and I wonder if it can also solve length values?

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 :

: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 which are also a kind of custom prop?

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:

--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

Dynamic utility classes

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:

: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.

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:

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

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

--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?":

--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?

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.

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:

image ...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.

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.

naming collisions

If I am in this situation:

: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})

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

Will we have an implicit Group creation if we define variables that share the same prefixes?

If for example I do the following:

:root {
  --color-red: red;
  --color-blue: blue;
}

Does it mean that, automatically, we have the group variable --color ?

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).

@olets
Copy link

olets commented Feb 25, 2024

Re:WRT base values (the comment you quoted was me not Adam. As a LESS proponent turned Tailwind proponent myself, I'll take the conflation 😄)

The main motivation is twofold:

  1. To ensure that groups produce a value when used in regular properties

Not sure that I'm understanding. I think you're saying to ensure that given --color-green: { base: /* … */; } we can my-component { color: var(--color-green); } and cannot my-component { color: var(--color-green); }? To me var(--color-green-base) is more intuitive there, and requires less special knowledge, than var(--color-green). I'd expect var(--color-green) to be an nothing as var(color-green-nope).

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.

I wasn't clear, that's not what I had in mind. But here's a contrived example for it: a palette with ten shades of green, where shade 3 is the starting point from the other nine shades are calculated, and where shade 5 is the "primary" shade (think the 400 font-weight). In the group definition you'd have top level names 1, 2, base, 4, primary, 6, 7, 8, 9, 10, and the variables would be green-1, green-2, green, green-4, green-primary, green-6, etc. Usable if you know the rule, but arguably not an accurate reflection of the design. Especially in colors where a designer might not give special distinct meaning to "base" vs "default", or might have their own idiosyncratic distinctions, and in CSS where we don't already have magic keys, I'm not sure the language should read meaning into any key name.

That's off the top of my head. What I had in mind:

Say the magic name is default, and the design's source of truth is outside code in some comp, and the design calls some shades "default". These are the downsides I've run into when putting the "default" shades under the key default:

  • deviation within the code: "green default" in the palette definition, "green" elsewhere
  • deviation between the design and the code. "green default", the name in the source of truth, is sometimes "green default" in the code and sometimes "green"
  • redesign complication. If all the "green default" needs to change to "dark red", you can't do a search and replace all (unless you know all the contexts in which the base shade of green is used, and are strong with regex). You search e.g. --color-green and manually identify the ones that need to change.
  • refactor complication. Say the designer renames "default" to "primary" and the code is to change to match. Again, brush up on negative lookaheads or do it manually.

I've hit all of those when using Tailwind's magic DEFAULT key for color shades called "default" in the design comp (and now favor the key default key, which isn't magic, has to be made explicit everywhere —text-blue-default— and resolves all four complications.)

tldr I want to have the choice to have a key "base" or "default", and for the language to not swallow that key (and I don't expect the language to let me leave off a key)

does that mean that we could groups in groups in groups

Yes.

Then does magic base entail more special rules, at least "no grouping under base"? To prevent --a-b ambiguity in

--a: {
  base: {
    b: 1;
  }

  b: {
    base: 2;
  }
}

@dutchcelt
Copy link

This is really very nice. I don't tend to have the colour mapping issue as described in the proposal. I do have it in other ways.

--color-link: { 
  base: darkblue;
  --hover: dodgerblue;
  --visited: purple;
  --active: crimson;
}

--typography-weight: {
  /* within the group refer to the extension name only? */
  base: var(--semibold); 
  --thin: 100;
  --extralight: 200; 
  ...
}

The scary part (okay, not very scary) is that changes to the extension names/keys could break further down the chain. But certainly more manageable than mapping everything by hand!

I wonder if it would be possible to preconfigure the extensions in an at-rule property.

@property --color-blue: {
  syntax: "<color>";
  extensions: 100 / 900;
}
@property --stroke-width: {
  ...
  extensions: thin | medium | thick;
}

In usage:

--color-blue: { 
  --10: lightblue; /* Would show a property error */
  --200: blue;
  --300: darkblue;
}
 

@brunoais
Copy link

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.

I think getting out of sync is not a problem. That's a user problem and not this problem. For me, that's perfectly fine.

I also agree with Crissov on the syntax. It's always better to use some sort of syntax than having a reserved word.

I also believe that if this is accepted, it should be seen as syntax sugar the same way that CSS nesting could have been.

@Anoesj
Copy link

Anoesj commented Feb 26, 2024

Great ideas here! Just reposting here what I already posted on X too.

Some people use BEM like notation for color variants, so e.g. --color-primary--400 (notice the double -). It would be nice to be able to define a variable like this to have full control over the variable variants notation:

:root {
  --color-primary*: {
    base: #00f;
    --100: #66f;
  }
}

The asterisk/wildcard at the end of the variable name could tell the parser: this is a variable group. To reuse a variable group, one can use:

.card {
  --card-color-primary*: var(--color-primary*);
}

This makes it very clear that we're reusing the entire variable group, not just the base value. Now if you were to leave out the asterisks, you would just use the base value only:

.card {
  --card-color-primary: var(--color-primary); /* no asterisks at the end */
}

@devongovett
Copy link

Seems like the syntax as proposed would potentially be a breaking change? The following CSS already parses as valid today:

.foo {
  --color-green: {
    100: oklch(95% 13% 135);
    200: oklch(95% 15% 135);
  };
}

Someone might use a syntax like this to share data between CSS and JS. Not sure how common that is but changing the custom property syntax to reserve { as having special meaning could potentially break things.

Maybe a new at-rule for defining these could work? Or add something to @property?

@brandonmcconnell
Copy link

I think that in JS, if we read the computed style for a grouped property value, it should return the full group rather than the base value. This way, JS can read the entire group and work with any of its values, just as it currently does.

It could be worth introducing new JS APIs to access and read/write grouped values, like this:

getComputedStyle(elem).getGroupedPropertyValue('--color', 'primary', '100')

I believe this addresses @devongovett's point as well. As he mentioned, that syntax was valid and would still be considered valid today. As long as JS continues to yield the same results it currently does and gets the full group, I don’t see this causing any breaking changes. I can’t speak for every library, though I myself do maintain a library for sharing data between CSS and JS in this fashion, and I don't think this would cause any breaking changes for my library, so long as JS still retrieves the entire property value, not just the base/default value.

Even without a new JS method for retrieving nested properties, a JS helper function could still be written to perform such a task as needed, so it's not really a blocker for this feature. A case could be made for requiring the use of the @property rule to define CSS custom property groups, possibly with a group: {boolean} value.

Nearly all CSS custom property values—including JS—parse as valid, though I don't think this would cause any tangible breaking changes, as this would just parse as valid in contexts where it isn't currently, e.g., used as the value for a color property. Passing it into another CSS variable or consuming it in JS would still retain the full grouped value as it does today.

That said, it does feel risky to have one CSS custom property essentially generate any number of others. This could be confusing for both new and seasoned developers trying to understand where a variable is coming from, e.g., searching their codebase for a static variable name --color-purple-light and finding no results.

I would personally prefer a dedicated syntax for referencing nested properties, such as a dot syntax, which would feel familiar and support both strings and numbers in CSS. So, this would be valid syntax: --color.purple.light. That approach would make it clearer when a nested value is being used, with the same level of simplicity, and also make it easier for any JS parsers to tell what is a variable name vs. a grouped/nested variable name.

I think the value of use cases for default and/or base is significant and could still be supported when a group is used as a value. However, this particular usage might require a @property rule—not sure. In either case, it would not be a breaking change.

@devongovett
Copy link

It has side effects though:

.foo {
  --foo-bar: red;
  --foo: {
    bar: green;
  };
}

If we make { have special meaning, now it is overwriting the existing --foo-bar variable, which might even be defined far away in another rule/element.

@brandonmcconnell
Copy link

brandonmcconnell commented Feb 26, 2024

@devongovett Yes, I too noted that concern earlier in this thread (under "naming collisions").

I do think that side effect is a rather significant one. Even for future development, it's not super clear that one variable group may overwrite any number of other variables.

I see a couple of possible solutions to this problem:

  1. Using a different syntax for grouped/nested property reference, like --foo.bar rather than --foo-bar.
  2. Using some sort of syntax to mark a property as a group (e.g. --foo*: { bar: green; }) would help to avoid any current breaking changes due to this side effect but would still risk side effects when used, though at least those side effects would be foreseen and even intentional if that special syntax is being used.

I personally find #1 to be a better solution for a number of reasons—

  • using a different ref syntax makes it clear that we're referencing a grouped/nested value, not just a regular variable name
  • this approach avoids naming collisions, as this would not overwrite anything

I elaborate on this in my previous comment.

@kizu
Copy link
Member

kizu commented Feb 26, 2024

After thinking a bit about it, I don't like the idea of magically creating other variables from a map, and will be ok with having just a get function, so instead of opacity: var(--foo-bar) for getting the value from a --foo: { bar: 1 }, we'd have to write opacity: get(--foo, bar), which is almost the same, but more explicit and without the side-effect.

The thing we'd need then is to have something like @set or something, which could be then used to set some key to some value for the current map, instead of the proposed way with the --foo-bar: …

@brandonmcconnell
Copy link

brandonmcconnell commented Feb 26, 2024

@kizu I'm not sure if you had a chance to read my previous comment(s), but the idea I proposed for a get/set syntax in my previous comment would account for both:

  • getting via --foo.bar.baz to avoid get(--foo, bar, bar) or get(baz, get(--foo, bar))

    ☝🏼 if we do stick with a helper function vs. a new syntax, I think get(--foo, bar, bar) would be highly preferable to get(baz, get(--foo, bar)), since nesting uses of that function makes the ordering of properties a bit wonky

  • setting via --foo.bar.baz: blue to set individual grouped properties

I think the dot syntax would simplify both getting and setting quite a bit.

@kizu
Copy link
Member

kizu commented Feb 27, 2024

@brandonmcconnell I think it could be nice to have the dot syntax, though there are two things to think about:

  1. Is this friendly for the parser? Which might be the biggest question and a potential issue for this to be implemented.
  2. Even if it would — we'd still need the get() and set() syntax for cases where the key is not a [\w-]+ string (as long as we'd allow it), unless we'd allow --foo[0]: etc, but at this point I would prefer just get().

@sorvell
Copy link

sorvell commented Feb 27, 2024

Yeah, this would be incredibly useful. Thanks @LeaVerou for such a well thought out proposal!

get

I think the get() idea should just be incorporated into the proposal directly as it would provide a lot of compositional power.

functions

Regarding some of the short-comings of using functions for this, some (but not all) could be addressed by just allowing functions/mixins to be passed/consumed as variables.

base

@LeaVerou said this was important because:

  1. To ensure that groups produce a value when used in regular properties

Why is that good? There's no guarantee that a variable value will work for any property, the type has to be suitable.

  1. Communication: In many design systems it's unclear what the "base" value is.

Most systems have stuff like --primary-color so I assume you mean there's red-100...900 but not red-base. That need seems design system specific. In material, for example, the defaults are really de-emphasized and the colors are all generated from a few input values.

the - delimiter

I think it would be nice to make this explicit since it's only really a common convention. @layer uses . so if there is a canonical delimiter would that be more consistent?

@Property and inheritance

What happens if there's an @property defined for a group sub-property that does not inherit and the group does inherit? Does the sub-property not get included in the inherited group?

@brandonmcconnell
Copy link

brandonmcconnell commented Feb 27, 2024

@kizu Good thoughts. I appreciate the feedback!

In response:

  1. Is this friendly for the parser? Which might be the biggest question and a potential issue for this to be implemented.

I do believe this would work with most/all parsers. This would be a new syntax, and to my knowledge, it does not clash with any existing syntaxes.

  1. Even if it would — we'd still need the get() and set() syntax for cases where the key is not a [\w-]+ string (as long as we'd allow it), unless we'd allow --foo[0]: etc, but at this point I would prefer just get().

I working be opposed to also introducing get/set functions though set might also require some changes to the parser, as it's not declarative like traditional CSS functions afaict.

I think we could still avoid the need for get/set functions with this new syntax though. Dot syntax is familiar, but I wouldn't recommend we also introduce bracket syntax.

Instead, I think the dot syntax could be used for both traditional and numeric characters alike. This is what I'm envisioning:

:root {
  --color: {
    bg: {
      dark: #030712;
      light: #f9fafb;
    }
    pink: {
      50: #fdf2f8;
      100: #fce7f3;
      500: #ec4899;
      700: #be185d;
      900: #831843;
    }
  }
  --color.primary: var(--color.pink);
  --color.primary.main: var(--color.primary.500);
}

Spreading values

--color.primary: var(--color.pink);
--color.primary.main: var(--color.primary.500);

Considering the above example, we may also want to think through some sort of/alternative to a spread syntax, so a variable group can get some/all the values from another group and also add items.

I'm not sure what that could look like yet in this context, but we’re discussing the spec for a spread function in #8391 already, which would be even more useful if we could reuse it in this context for grouped variables, especially as the use case and syntax are similar.

Hypothetically, spread() could wrap other variable or inline-set values in a grouped variable's value definition:

--color.primary: spread(
  /* spread all */
  var(--color.pink),

  /* pick some  */
  var(--color.pink) { 50, 500 },

  /* pick some, w/ optional aliasing 🤷🏻‍♂️  */
  var(--color.pink) { light: 50, main: 500 },

  /* any other custom values */
  {
    random-color: cyan;
    random-string: "hello world";
  }
);

In simpler situations, this would look like this:

--color.primary: spread(var(--color.pink), var(--color.pink) { light: 50, main: 500 });

As the author of a CSS/JS lib myself, I know it would technically be considered a breaking change to wrap a variable value in a function like this, but I don't believe this would break any core functionality of any CSS/JS parsers/libs. That said, we should still do a survey of other library authors/maintainers if we go that route.

@tabatkins
Copy link
Member

(Sorry, I've only skimmed the rest of this thread; it got real long real fast.)

Overall, several good ideas in here. I think it could do with a more holistic look at the use-cases, tho - as written, it gradually grows more complex and reinvents itself in several forms. I think deciding on what use-cases to address, and solving them directly, will result in a better proposal than what's currently written.


Being able to define parametrized groups of variables and pass them around as a group makes sense. There's several distinct ideas here:

  1. Being able to rename an entire group of variables with one command (rather than having to write out every single variable in the group and rename them individually).
    • Sub-idea: assuming variables/mixins do need to declare the variables they can see, being able to declare an entire group's visibility at once would be useful.
  2. Automatic grouping of variables by prefix, so later users can extend the group with new properties as needed, and have them automatically included.
  3. Being able to define a single "generic" variable, and then associate sub-values with it. (The example of --color-green being the "representative" green, and then --color-green-100/etc being specific tints.)
  4. Parametrized variables (aka cascading custom functions?), where you gain access to a special variable representing the parameter.
  5. Being able to refer to parametrized/grouped variables dynamically, with the parameter/identifier supplied as an argument rather than being part of the variable name.

I think being able to refer to variables by prefix, and possibly rename them by prefix, makes sense. The syntax suggestions are fairly lightweight, and it doesn't meaningfully overrun any other functionality in the language.

We will need some syntax to differentiate it from existing variables, tho. The existing syntax space is wide-open, intentionally, so we can't just infer grouping from the property name, or from the value being a {}-block. But something like --foo-*: {...} is possible, where the * token indicates it's not a standard variable, and has a specific syntax that's a {} block.

I would love to see specific examples of people using design systems and running into these problems, however, to make sure we are indeed solving their problem. This is designing syntax/tooling, and the use-cases are necessarily more abstract, but we still need to be sure we're actually helping people.


As we get further down the list, tho, we're basically just doing functions. (Or array/dict lookups, which can be seen as particularly simple functions; or vice versa, functions are particularly complex array lookups.) We already have a proposal for doing CSS functions, and I don't think we should do functions twice, with different syntaxes, unless there's a really good reason.

Near the end, you argue against the usage of custom functions for handling several of these. I think we should look at this possibility more carefully, as functions do substantially overlap in use-cases here.

You mention having to write a new function as being heavyweight; I acknowledge it's certainly heavier than writing some variant of --color-primary-*: var(--color-red-*);, but I don't think this is something you'd do repeatedly. You'll set up your desired variable names at the top of your stylesheet, and then never think about them again.

(But still, if we did introduce something like the --foo-*: ... syntax, allowing it to accept a variable-group reference like --color-primary-*: var-group(--color-red-*); would make sense, in addition to directly defining the group via a {} block.)

Which part is variable is part of the syntax, so e.g. in the example above, there is no clear path to defining a --color() function from that.

I think this is a plus, fwiw. Making it clear what's variable and what's not is a good thing; people shouldn't be assuming that every dash-separated suffix of a variable name can be removed to form a more generic variable.

A --color() could exist, tho, once we actually introduce the conditional functions we've bandied about. (And we should do so, as part of the custom functions proposal, imo.) That way you could write --color(primary, 40) if you wanted, and have it handled by something like:

@function --color(--type, --tint: 40) {
	result: cond(
		(var(--type) == primary) --color-primary(var(--tint)),
		...
	);
}

Or an equivalent syntax with at-rules.

There is no way to pass a few key colors to a component or subtree and have the rest be computed from them. In fact, we cannot pass functions around at all, only the result of their invocation.

Sure there is. You can't pass functions around the cascade, but functions can depend on custom properties, so like:

@function --color-green(--tint: 400) 
	using (--color-green-100: oklch(...), --color-green-900: oklch(...)) {
	result: color-mix(in oklch, var(--color-green-100) calc((100 - var(--tint) / 10) * 1%), var(--color-green-900))
}

This calculates the full green spectrum off of the start/end greens, and allows them to be overridden in any subtree.

This approach works far better for tints that are generated as samples on a continuous axis. It is unclear how a set of predefined tints would look like as something like that.

Assuming the conditional functions end up existing, then you could always test for the precise values you want to define for, and let any non-matching values resolve to something invalid. Or you can round to the nearest value you want to handle. Several possible options, and it gets larger as we expand CSS's value space.


I think the biggest issue with using functions is that it wouldn't mesh with your "automatic grouping by prefix, so others can extend it" goal. The --color() example I gave above, for instance, can't be extended to take more keywords. This might be addressable directly, tho!

Like, if we think this sort of "define groups of functions with a common prefix, and dynamically dispatch to them" will be common, we could just make dynamic dispatch work. Like:

@function --color(--type, --tint: 40) {
	result: function-call(--color / var(--type), var(--tint));
}

The first argument to function-call() here is a slash-separated set of idents, which get composed into a dash-separated function name, which is then passed the remaining arguments. With this, then, if you define --color-primary(), you could call --color(primary, 40) and it would Just Work, and similarly if someone defined a --color-green(), etc.

@LeaVerou
Copy link
Member Author

LeaVerou commented Mar 1, 2024

Thanks for the careful look @tabatkins, I’ve been looking forward to your reply!

There's several distinct ideas here:

That's a nice overview. I think the decomposed proposal may mesh better with that line of thinking.

We will need some syntax to differentiate it from existing variables, tho. The existing syntax space is wide-open, intentionally, so we can't just infer grouping from the property name, or from the value being a {}-block. But something like --foo-*: {...} is possible, where the * token indicates it's not a standard variable, and has a specific syntax that's a {} block.

Yeah, I think that makes a lot of sense, and removes a lot of the magic.
Though does that mean the wildcard could be preceded by anything? Would people be able to do --foo*: {s: ...} and reference it as --foos?

A few thoughts as I read your reply:

I would love to see specific examples of people using design systems and running into these problems, however, to make sure we are indeed solving their problem. This is designing syntax/tooling, and the use-cases are necessarily more abstract, but we still need to be sure we're actually helping people.

Did you see the entire table of popular design systems that I included in my proposal?

Functions

It's not just about verbosity. I have no idea how you'd offer something where the design system specifies the ends and the midpoint, and the other tints are automatically computed BUT you can also add hand-tweaked variants to influence how the interpolation works. Though perhaps that is better addressed in mix()/color-mix().

As we get further down the list, tho, we're basically just doing functions. (Or array/dict lookups, which can be seen as particularly simple functions; or vice versa, functions are particularly complex array lookups.) We already have a proposal for doing CSS functions, and I don't think we should do functions twice, with different syntaxes, unless there's a really good reason.

Groups are analogous to objects/dicts, not functions. Dynamic groups do encroach into function territory, but they are more analogous to JS proxies than functions.

You could argue that everything can be implemented via functions (and Lisp even argues that everything can be implemented via lists) but typically languages offer data structures as well, because implementing data structures as functions is painful AF.

You mention having to write a new function as being heavyweight; I acknowledge it's certainly heavier than writing some variant of --color-primary-*: var(--color-red-*);, but I don't think this is something you'd do repeatedly. You'll set up your desired variable names at the top of your stylesheet, and then never think about them again.

It's not primarily about it being heavyweight (though as an author, looking at your code examples of how it could be done has me going NOPE NOPE NOPE I’d take the repetition over this 😅). That's the least of it.

I think possibly the biggest issue is around encapsulation. Whether the palette is defined as continuous or not, or somewhere in between should be an implementation detail, not drive how you refer to design tokens. The final user of the design system should not have to care about whether --color-red-200 is specified manually, or the result of interpolation between e.g. --color-red-100 and --color-red-400.
And it should be possible to change between the two, without uses having to be updated. E.g. you notice that your generated --color-yellow-800 is crap and want to insert a stop in terms of how the interpolation happens, that should be possible without users of the design system having to update their code (and other shades near it should also benefit from the adjustment).

Also, unless we introduce some kind of syntactic convenience, having to use functional syntax is an all-or-nothing preposition which does not play nicely with composition across a distributed ecosystem, while my proposal had paving the cowpaths and requiring as little shared contract between context and components as possible as explicit goals.
Meaning that once YOUR design system uses functions, every design system you use needs to use functions, or you're stuck doing manual mappings again.
E.g. if a component is expecting --color-primary, --color-primary-100, etc. and all I have is --color-red(100), there is no way to map the two without a lot of repetitiveness.

But even if the component did use functions for its design system, how would you pass these to a component? None of the proposals around functions includes a concept like a function reference.
If I want to set a component's --color-primary (and its tints) to my --color-magenta (and its tints), how would that work with functions?

@tabatkins
Copy link
Member

Though does that mean the wildcard could be preceded by anything? Would people be able to do --foo*: {s: ...} and reference it as --foos?

I suspect we don't want to allow that, just because that means we have to be a lot more literal with the nested bits. Like, we'd have to allow --foo*: {s: ...; }, which means we're closing off the ability to do any of the base/etc stuff. Whereas if we require a dash, we can require it on both ends, like --foo-* { --bar: ...; } and keep the namespaces nicely separated. (Also, requiring a -- on the nested bits keeps the parsing working nice even when the bits are just numbers, like --gray-* { --500: ...; } works right now while --gray-* { 500: ...; } doesn't.)

Did you see the entire table of popular design systems that I included in my proposal?

Yes, but that's not what I was asking for. I'd want to see worked-out examples of how such design systems would use this, and how it would make their usage of their own design systems easier and more reliable. Ideally every reader of the proposal doesn't have to do the working out themselves. ^_^

It's not just about verbosity. I have no idea how you'd offer something where the design system specifies the ends and the midpoint, and the other tints are automatically computed BUT you can also add hand-tweaked variants to influence how the interpolation works. Though perhaps that is better addressed in mix()/color-mix().

Can you give an example?

Groups are analogous to objects/dicts, not functions. Dynamic groups do encroach into function territory, but they are more analogous to JS proxies than functions.

You could argue that everything can be implemented via functions (and Lisp even argues that everything can be implemented via lists) but typically languages offer data structures as well, because implementing data structures as functions is painful AF.

My argument isn't that we should treat data structures and functions as equivalent. It's that your proposal spans the full gamut from "obvious data structure" (variable groups) to "obvious function" (variables with continuous variation), and we should be careful about how we handle this.

CSS is already going to grow functions in some way. Do we want two distinct function syntaxes - one defining globally using at-rules, and one defining locally using properties? If so, how much can they differ in power/expressiveness? How much flexibility do we even have in declaration syntax? How much will this cost us in terms of future syntax flexibility?

Currently, I'm very hesitant in trying to define a separate, second function syntax, and extra hesitant about doing so within the bounds of declaration syntax. I would need an extremely compelling argument that it's both necessary, and the only way to do so, before I'd be willing to go for it. (I'm super extra hesitant about doing any of this before we have even actually defined the first type of functions.)

Because we still have to answer the question: how much must we do? Can we do a fairly small feature (variable groups) that solves 90% of the problem, and just skip the last 10%, thus avoiding having to define a massive new feature (declaration-syntax cascading functions masquerading as custom properties)? If that 10% does need to be solved, are there other ways to do it that aren't as heavyweight of a feature?

Maybe the split is actually that the small, easy feature can only handle 50%. Maybe the remaining issues, while unsolved, are fine to just leave as something that's a little awkward to write.

In the absence of answers to those questions (reasonable, because this is a very early exploration into the space!), I'm trying to push on the boundaries a bit. How much can we solve using existing (or planned) features instead? If we do have to introduce new features, what other possibilities exist that might be lighterweight in syntax and/or functionality?

I think possibly the biggest issue is around encapsulation. Whether the palette is defined as continuous or not, or somewhere in between should be an implementation detail, not drive how you refer to design tokens. The final user of the design system should not have to care about whether --color-red-200 is specified manually, or the result of interpolation between e.g. --color-red-100 and --color-red-400.

Generally speaking, I disagree with this! What you expose and how you expose it are important considerations in API design. If, in JS, I hand my user an object and they're expected to access properties on it, that implies that there's a finite, predetermined number of values I can access. If I hand them a function that takes a continuously-varying argument, that implies that there's a continuously-varying output value. If I swap either of these, I'm somewhat violating expectations, and better have a really good reason for doing so.

Of course, in a full programming language, you can somewhat mask the difference. If I hand the user an object with a few values in it, they don't need to know whether it was constructed from an object literal, computed completely dynamically, or some combination of the two. We can't do this in CSS, and the current planned approaches do indeed require you to commit to one or the other.

And it should be possible to change between the two, without uses having to be updated. E.g. you notice that your generated --color-yellow-800 is crap and want to insert a stop in terms of how the interpolation happens, that should be possible without users of the design system having to update their code (and other shades near it should also benefit from the adjustment).

As an example of a different possible approach, perhaps the use-case here can solved in a more narrowly-targeted fashion. For example, maybe we can define a "value ramp" (a few types - numeric, color, others) that represents a range of interpolated values, can be passed around in custom properties, queried for particular values on the ramp, and extended by others. Like:

/* any number of colors can be given */
--color-primary: color-ramp(in oklch,
                            100 oklch(95% 13% 135),
                            900 oklch(25% 20% 135));

/* you can ask the ramp for any value in the range */
background: get-ramp(var(--color-primary), 400);

/* you can create a ramp from an existing one 
   by adding or overriding stops */
--better-primary: color-ramp(from var(--color-primary),
                             800 forestgreen);

Or maybe the ramp is defined by an at-rule, with the ability to reference custom properties from point-of-use; that might give us a richer syntax to play with, especially for overriding. Like:

@color-ramp --primary {
	stops: 100 oklch(95% 13% 135),
           900 oklch(25% 20% 135);
    interpolation: oklch;
}
@color-ramp --better {
	extends: --primary;
	stops: 800 forestgreen;
}

.foo { background: color-ramp(--primary 400); }

This could sprout more abilities, too, like the ability to round the input to some precision (only every 100, or 50, or whatever), define whether it extends past the first/last stops, etc.

This sort of approach would also suffer from the "everyone has to use this method" problem you outline, but it's also vastly simpler than "inline functions defined in the declaration grammar", much more extensible, can be specialized to the problem space in important ways, etc.

Are all the design-system cases that want generative/continuous values doable with something like this? Are they all just sizes and colors, or other things that might similar fit into this framework? We'd need to see. Maybe they would all just switch to this sort of value, were it provided, so you wouldn't need to worry about some using "a whole bunch of values stored in individual properties" that required a lot of manual renaming.

@LeaVerou
Copy link
Member Author

LeaVerou commented Mar 6, 2024

I suspect we don't want to allow that, just because that means we have to be a lot more literal with the nested bits. Like, we'd have to allow --foo*: {s: ...; }, which means we're closing off the ability to do any of the base/etc stuff. Whereas if we require a dash, we can require it on both ends, like --foo-* { --bar: ...; } and keep the namespaces nicely separated. (Also, requiring a -- on the nested bits keeps the parsing working nice even when the bits are just numbers, like --gray-* { --500: ...; } works right now while --gray-* { 500: ...; } doesn't.)

I actually agree with this. It's a little uglier, but I think the lack of ambiguity is worth it.

Did you see the entire table of popular design systems that I included in my proposal?

Yes, but that's not what I was asking for. I'd want to see worked-out examples of how such design systems would use this, and how it would make their usage of their own design systems easier and more reliable. Ideally every reader of the proposal doesn't have to do the working out themselves. ^_^

I’m still not super sure what you’re asking. The variables they define today are right there, but they're obviously hardcoded today.

Can you give an example?

I gave several in #10034. Though this kind of approach does restrict the interpolation tweaking to the design system author, but perhaps that's ok? That said, if we could find a way to allow tweaking from the outside, even better.

My argument isn't that we should treat data structures and functions as equivalent. It's that your proposal spans the full gamut from "obvious data structure" (variable groups) to "obvious function" (variables with continuous variation), and we should be careful about how we handle this.

Can't disagree with being careful! :) But that applies to everything we do :)

CSS is already going to grow functions in some way. Do we want two distinct function syntaxes - one defining globally using at-rules, and one defining locally using properties? If so, how much can they differ in power/expressiveness? How much flexibility do we even have in declaration syntax? How much will this cost us in terms of future syntax flexibility?

I don't see this as two function syntaxes, in the same way that JS accessors and proxies are not alternative function syntaxes. But these are all questions we should figure out.

Currently, I'm very hesitant in trying to define a separate, second function syntax, and extra hesitant about doing so within the bounds of declaration syntax. I would need an extremely compelling argument that it's both necessary, and the only way to do so, before I'd be willing to go for it. (I'm super extra hesitant about doing any of this before we have even actually defined the first type of functions.)

I think it may be worth exploring implementability (especially compared to functions). What if shipping dynamic properties first could cover enough use cases that we can then take our time fleshing out functions?

But I do think that the use cases are distinct, although there is certainly overlap. In some ways, this is like saying we shouldn't work on if() before @if.
Dynamic properties are more limited in terms of arguments, but also have full access to context, are defined inline so are easier to debug, and (the biggest benefit IMO) cascade and inherit like normal properties.

Because we still have to answer the question: how much must we do? Can we do a fairly small feature (variable groups) that solves 90% of the problem, and just skip the last 10%, thus avoiding having to define a massive new feature (declaration-syntax cascading functions masquerading as custom properties)? If that 10% does need to be solved, are there other ways to do it that aren't as heavyweight of a feature?

I think the most pervasive pain point is the mass property renaming / passing around. So while I'm not sure if it would be 90% or 80% or 70%, I think addressing that is the bigger priority.

In the absence of answers to those questions (reasonable, because this is a very early exploration into the space!), I'm trying to push on the boundaries a bit. How much can we solve using existing (or planned) features instead? If we do have to introduce new features, what other possibilities exist that might be lighterweight in syntax and/or functionality?

I think decomposing the problem into a bunch of lower level features that can ship independently is often a good path (and what I was trying to do with the alternative decomposed design).

Generally speaking, I disagree with this! What you expose and how you expose it are important considerations in API design. If, in JS, I hand my user an object and they're expected to access properties on it, that implies that there's a finite, predetermined number of values I can access. If I hand them a function that takes a continuously-varying argument, that implies that there's a continuously-varying output value. If I swap either of these, I'm somewhat violating expectations, and better have a really good reason for doing so.

While I agree that it depends on the case, and in many cases you definitely want to expose that. But there are also many legit cases where that is an implementation detail, which is why there are entire programming language features designed to mask exactly that (e.g. accessors and proxies in JS).

Also keep in mind that the tokens the design system desires to expose may still be finite — it's the definition that is continuous. In the same way that typeface designers may design a typeface by interpolating between faces, then export a finite number of faces.

As an example of a different possible approach, perhaps the use-case here can solved in a more narrowly-targeted fashion. For example, maybe we can define a "value ramp" (a few types - numeric, color, others) that represents a range of interpolated values, can be passed around in custom properties, queried for particular values on the ramp, and extended by others.
[snip]

This is fascinating, I posted #10034 last night before reading this, and it looks like we’ve been thinking along very similar lines. I agree the piecewise interpolation stuff was not a good fit for this proposal and I have now removed it.

Some comments as I read through your proposal:

First, I see the benefits an @-rule would bring, but I don't think they outweigh the cons:

  • I would really like to avoid anything that has to be tree-scoped, like an @-rule.
  • I think it should be a requirement that it should be possible to pass these scales around with variables, so an @-rule is not conducive to that either.
  • Defining an @-rule feels much more heavyweight and forces you to find a unique id. E.g. I've seen authors go to some pretty wild lengths to use transitions instead of animations because of this this.

Wrt overridding the interpolation, there are two use cases here:

  • The design system author doing the overriding, e.g. to make darker yellows orangish, to spread out lighter tints etc. I think that's the bigger use case around overriding, and an inline function caters to that just fine, as it's defined by the design system author.
  • The design system user doing the overriding, e.g. the red 900 tint is too dark for my liking, I’ll make it a bit lighter. That is far lower priority.

I don't see why we'd bake the specific tint levels into the ramp. I think one of the advantages of a rank primitive is to abstract the naming scheme away, and progress() makes it easy to apply it externally. I think that's more of a nice to have, and not that important to address. It also seems to basically be introducing a new alternative color stop syntax, when we already have a color stop syntax for exactly this very thing!

I really like the idea of generating ramps from existing ramps. I think that is very powerful, and does this without having to resort to an @-rule. Amazing!

This could sprout more abilities, too, like the ability to round the input to some precision (only every 100, or 50, or whatever), define whether it extends past the first/last stops, etc.

Yes!

This sort of approach would also suffer from the "everyone has to use this method" problem you outline

Not necessarily. Design systems could still generate aliases that call color-ramp() internally. It's not ideal, but it's better than the current situation.

Are all the design-system cases that want generative/continuous values doable with something like this? Are they all just sizes and colors, or other things that might similar fit into this framework? We'd need to see. Maybe they would all just switch to this sort of value, were it provided, so you wouldn't need to worry about some using "a whole bunch of values stored in individual properties" that required a lot of manual renaming.

I see these as orthogonal problems. Providing better tools for ramps is useful in its own right (which is why I opened #10034). Like I said, I suspect design systems will still alias points along the ramps to variables — now if we give them a way to make this less repetitive in the future, even better!

@jpzwarte
Copy link

When something like this were to be implemented, it would be really nice if whatwg/html#6064 would be available as well. Then you could have:

<link rel="stylesheet" supports="<supports-condition-for-var-groups>" href="var-groups-dist.css" />
<link rel="stylesheet" supports="not (<supports-condition-for-var-groups>)" href="transformed-var-groups-dist.css" />

So the browser would choose which one to load by itself; no JS required.

@jpzwarte
Copy link

What are the next steps for this proposal? As a design systems developer, this would really help.

@LeaVerou
Copy link
Member Author

Sharing this video with permission, as I think it illustrates the pain point better than anything I've ever seen:

https://youtu.be/JhfYeXLfWdI?si=vF3xRai2QrjabVFv&t=277

image

Another use case is rebranding: Shoelace (variables starting with --sl-* is getting rebranded to Web Awesome (--wa-*), but the old variables must still work. You can imagine how painful this is to implement, but with something like this it can become a single line of code.

@jcubic
Copy link

jcubic commented Apr 1, 2024

This will break existing behavior. I've written an article how to use JSON inside CSS variable. This will break this functionality. There is a need for new syntax for the group (like a new keyword). Not just curly braces that right now is valid CSS custom property value.

This is perfectly valid CSS:

div {
    height: 100vh;
    background-image: paint(circle);
    --pointer-x: 20px;
    --pointer-y: 10px;
    --pointer-options: {
        "color": "rebeccapurple",
        "width": 20
    };
}

reference: Controlling Paint Worklet with JSON in CSS

@adamwathan
Copy link

@jcubic Make sure to read the whole discussion, that's been brought up a few times already and is part of the motivation for the --color-red-*: { ... } syntax 👍🏻

@jcubic
Copy link

jcubic commented Apr 1, 2024

@adamwathan there are a lot to digest here. There are also different proposals. I just wanted to be sure that this one will not be the part of the spec.

@brandonmcconnell
Copy link

@jcubic Hey Jakub, nothing is set in stone at this point. As you mentioned, there are several proposals to unpack here and likely concerns to address with each of them, as has been the case for many new CSS features. CSS nesting, for example, went through like 4-5 rounds of revisions, if not more, before settling on the final syntax.

As a JS-in-CSS (vs. CSS-in-JS) package author, I leverage the approach you mentioned but could work around any breaking changes resulting from the syntax proposal you called out.

Do you have a specific library or package in mind that would be impacted by that syntax, specifically in a way that would affect consumer APIs? Adjusting library/framework APIs under the hood is more manageable and can often flow with API changes. When I started throwing JS into CSS variable values, I knew perfectly well that what I was doing was fringe at best and could be subject to change.

That said, as @adamwathan pointed out, this thread seems to be leaning toward the -* syntax.

@jcubic
Copy link

jcubic commented Apr 1, 2024

@brandonmcconnell No, I don't have any specific library. Just bump into the issue in one of the newsletters (I'm way behind on reading them) and realized that the original proposal will break my demo and article.

@brandonmcconnell
Copy link

@jcubic I'd love to hear more about your use case. I just DM'd you on X, so we can keep that discussion going offline.

@LeaVerou
Copy link
Member Author

LeaVerou commented Apr 1, 2024

If the content is valid JSON, properties need to be quoted, so that wouldn't be valid CSS syntax. So depending on how parsing is defined, even the original proposal may not actually break your demo.

That said, when we did research on this (for CSS Nesting), the fraction of custom properties whose value was JSON was tiny.

@jcubic
Copy link

jcubic commented Apr 1, 2024

Sure, but now you can put anything into a CSS variable, including working JavaScript. See this article by Chris Coyer.

This is probably not very useful, but someone somewhere may rely on this feature and you may break that user code.

@brandonmcconnell
Copy link

@jcubic Even with that syntax, it's very likely that the value received by JS for the parent property, --pointer-options in your example, for instance, would remain the same. In that case, there would likely be a new Web API method introduced to the CSSStyleDeclaration interface similar to getPropertyValue() to parse grouped variable values.

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