Open LeaVerou opened 6 months ago
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.
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
?
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 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
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.
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?
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);
}
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:
--list: { 0: foo; 1: bar; }
we could do something like --list: [foo, bar]
?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”)/I'll need to spend more time thinking about this proposal, but I just want to reiterate how excited I am about it.
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 doget(--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! 🙂
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:
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.
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.
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. 👏
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:
...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.
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.
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:
@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).--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.
-
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.
get()
idea for that.Specific replies:
@romainmenke
> 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?
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
> 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.
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-*));
}
> 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? > > >
The main motivation is twofold:
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.
>
>
> ...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 doget(--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 likefallback
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.
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
withdark
and75
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
> 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` ?
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).
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:
- 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
:
--color-green
and manually identify the ones that need to change.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;
}
}
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;
}
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.
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 */
}
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
?
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.
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.
@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:
--foo.bar
rather than --foo-bar
.--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—
I elaborate on this in my previous comment.
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: …
@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, baz)
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.
@brandonmcconnell I think it could be nice to have the dot syntax, though there are two things to think about:
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()
.Yeah, this would be incredibly useful. Thanks @LeaVerou for such a well thought out proposal!
I think the get()
idea should just be incorporated into the proposal directly as it would provide a lot of compositional power.
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.
@LeaVerou said this was important because:
- 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.
- 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.
-
delimiterI 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?
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?
@kizu Good thoughts. I appreciate the feedback!
In response:
- 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.
- Even if it would — we'd still need the
get()
andset()
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 justget()
.
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);
}
--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.
(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:
--color-green
being the "representative" green, and then --color-green-100
/etc being specific tints.)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.
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?
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.
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:
@
-rule. @
-rule is not conducive to that either.@
-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:
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!
When something like this were to be implemented, it would be really nice if https://github.com/whatwg/html/issues/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.
What are the next steps for this proposal? As a design systems developer, this would really help.
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
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.
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
@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 👍🏻
@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.
@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.
@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.
@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.
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.
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.
@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.
@group --base { padding: 1em; } @group --color { --primary: teal; --secondary: hotPink; } .access { margin: var(--base.padding); border-color: var(--color.--primary); }
Using a dot separator between levels of grouped properties vs. a hyphen would help disambiguate in cases of naming collisions and provide a clearer method (to developers and parsers) when a property ref is looking at a name vs. entering a group. @mirisuzanne briefly mentioned this syntax here as well in the related "Design of @nest
rule" issue, and I suggested it previously above.
--some-property: var(--foo.bar.baz);
/* as opposed to `get(--foo, bar, baz)` or `get(baz, get(--foo, bar))` */
Using a dot syntax would also help to disambiguate grouped property names from ones with hyphen characters:
@group --test-* {
a: { b: 1; };
b: { a: 2; };
a-b: 3;
b-a: 4;
}
In this example, with hyphen-delimiting, var(--test-a-b)
and var(--test-b-a)
would each hold two meanings, and it's unclear if the latter for each would directly override the former as is standard in traditional CSS (i.e. do a:{b:value}
and a-b:value
set the same sub-property value?).
With dot-delimiting, these would not be confusable, as each group level would be explicitly delimited by a .
and gettable or settable using the dot syntax without needing to implement a separate system to override a specific value in a nested property.
var(--test.a.b)
, var(--test.b.a)
, var(--test.a-b)
, and var(--test.b-a)
would each be unique.
Setting a sub-property might look like this:
:root {
/* set up group */
@group --test-* { a: { b: 'abc'; }; a-b: 'xyz'; }
some-selector {
/* override sub-property values */
--test.a.b: 'a.b';
--test.a-b: 'a-b';
/* tests */
--check-grouped: var(--test.a.b); /* a.b */
--check-hyphen: var(--test.a-b); /* a-b */
}
/* tests */
--check-grouped: var(--test.a.b); /* abc */
--check-hyphen: var(--test.a-b); /* xyz */
}
@LeaVerou Let me know if you want me to open a new ticket to discuss the getter/setter syntax.
I would strongly prefer not using an @rule
here, for a number of reasons:
*
should be enough of a signifier that this is a group, and makes scoping more clear. Scoping is very unclear for nested @rules
, since for many of them .foo { @rule {} }
is rewritten to @rule { .foo {} }
. I’m also not a fan of the dot syntax. One of the goals of this proposal was to pave the cowpaths and facilitate the patterns that are already in wide use, not introduce a new syntax pattern that needs to gain traction. Also, I think it’s a good design goal that it should not matter how a design token was defined (whether it was a group or an explicit variable declaration) at the point of usage. I.e. my component should neither need to know nor care how you defined your design tokens.
@LeaVerou I would also prefer not to introduce an extra @-rule like @group
here. I merely included that as I've seen that art floating around that included it.
My main focus was the dot syntax, though I understand your feedback and concern there too.
For this example…
--test-* {
a: {
b: {
base: 'abc';
extra: 'def';
}
};
a-b: 'xyz';
}
Without an alternative getter/setter syntax like the dot syntax…
Would a:{b:{...}}
and a-b:value
both target the same sub-property value?
a-b
under --test
after setting up a:b
as a group overwrite its group value (naming collision), or would it set the base
value for that respective group, if one exists?Would sub-groups need to use the same -*
group syntax?
--test-* {
a-*: {
b-*: {
base: 'abc';
extra: 'def';
}
};
a-b: 'xyz';
}
-*
syntax for sub-groups, would the object under a
(a:b
, not a-*:b-*
) be seen as a plain string, as is currently standard practice in CSS?a-b
overwrites the value of a-*:b-*
?Would setting the plain a-b
sub-property value before setting up the a-*:b-*
group still preserve that value as the base
value, or would it be completely overridden when the new group is instantiated, if they would otherwise point to the same value?
--test-* {
a-b: 'xyz';
a-*: {
b-*: {
base: 'abc';
extra: 'def';
}
};
}
If the dot syntax is not needed for a getter syntax and names can be referenced using -
as the sole delimiter without risking naming collisions, would we need the get()
function others have proposed, as that appears to serve the same purpose?
How would one go about setting a specific sub-property, without affecting others?
--test-* {
a-*: {
b-*: {
base: 'abc';
extra: 'def';
}
};
}
--test-a-b-extra: 'xyz';
effectively do that, rewrite the sub-property --test-*:a-*:b-*:extra
, while leaving the rest of the group(s) intact?--test-a-b
rewrite the base value if --test-a-b
is a group, whereas setting --test-a-b-*
would rewrite the group itself?Are there any plans as of now to introduce a way to pass groups or sub-groups to other custom-properties? Something like one of these:
--custom-*: --test-a-b-*;
--custom-*: var(--test-a-b-*);
--custom-*: group(--test-a-b-*);
--custom-*: --test-a-b;
--custom-*: var(--test-a-b);
--custom-*: group(--test-a-b);
base
/default
sub-propertyTo allow developers to name sub-properties whatever standard idents they wish, could we consider a pseudo-property name for the base
keyword rather than making that a reserved word? Perhaps :base
or :default
to mirror the DEFAULT
keyword used by Tailwind CSS?
Several others have voiced a similar concern—@kizu @adamwathan @Crissov @olets @tabatkins—which I think using a pseudo here might account for, though :default: …
isn't exactly common syntax either, as we have pseudo-classes and pseudo-elements, but not necessarily pseudo-properties, at least yet.
Tab Atkins' idea of always requiring custom sub-properties to start with --
would negate this risk entirely, so only named properties could be used in groups, and default
could be one of those named properties:
--test-* {
--a-*: {
--b-*: {
base: 'abc';
--extra: 'def';
}
};
}
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.
--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.--color-green-100
will override the inherited one from--color-green
(just like with shorthands).Using groups on non-custom properties: The
base
propertyAuthors 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.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:Together with something like
color-scale()
this could also facilitate piecewise interpolation.Alternative decomposed design
We could decouple this into three separate features:
--color-primary: group(--color-green)
or evenvar()
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).--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!