Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[css-color-6] How to support color math involving more than one color? #11533

Open
LeaVerou opened this issue Jan 18, 2025 · 0 comments
Open

Comments

@LeaVerou
Copy link
Member

LeaVerou commented Jan 18, 2025

There are many use cases that require doing math on components from more than one color, and this is currently impossible without having separate variables for each component.

Example use cases

In the following I'll use an extension of RCS that supports additional colors via the same idents with a number after their name (e.g. c2 for the second color's chroma while the first one remains c). The next section contains a more detailed syntax discussion.

Note

Yes, many of these would be better solved with higher level features that are more specific to the use case. However, the argument I'm making is that this is a low-level feature that makes many use cases possible, giving us more time to make them easy, which was also a big motivation behind RCS itself.

1. Combining components from multiple colors

Lightness from one and hue & chroma from another

--color-accent-80: lch(from accentColor var(--color-blue-80) l2 c h);

Applying the same ratio of chromas would take 3 colors (using blue as a sort of "template" for the chroma ratio)

--color-accent-80: lch(from accentColor var(--color-blue-80) var(--color-blue) var(--color-neutral) l2 calc(c * c2/c3) h);

We've also had several use cases for combining color components from one color and alpha from another but I can't find them right now, one was even high priority as it was needed for a11y. Does anyone have a link handy?

Custom contrasting text color

This also came up when generating text colors automatically. Both this trick, as well as contrast-color() generate white and black, but in reality you rarely want black (or even "a very dark color"), you want one of your actual design tokens! white is often acceptable as the light color, but black (or even "a very dark color") rarely is.

--l: clamp(0, (l / var(--l-threshold, 0.645) - 1) * -infinity, 1);
color: oklch(from var(--color) var(--l) 0 h);

/* or, once contrast-color is a thing: */
color: contrast-color(var(--color) max);

With multiple colors, you could do (picking between white and a dark color:

--color-bw: color: contrast-color(var(--color) max);
--p: progress(l, 0, 1);
color: oklch(from var(--color-bw) var(--color-dark) calc-mix(var(--p), l2, l) calc-mix(var(--p), c2, c) calc-mix(var(--p), h2, h));

or, to customize both the light and dark color:

--color-bw: color: contrast-color(var(--color) max);
--p: round(progress(l, 0, 1)); /* round to either 0 or 1 */
color: oklch(
	from var(--color-bw) var(--color-dark) var(--color-light) 
	calc-mix(var(--p), l2, l3) calc-mix(var(--p), h2, h3) calc-mix(var(--p), h2, h3)
);

If the "repeating the list" idea from below is implemented, the same formula could be used with either 2 or 3 colors, and would just fall back to white if no light color is specified and to white and black if only one color is specified.

Implementing light-dark() (if it didn't exist)

If light-dark() were not a thing, the same formula could be used for that too, to pick one of two colors based on whose lightness was closest to that of canvas (or canvastext):

--color-bw: color: contrast-color(var(--color) max);
--p: round(progress(l, 0, 1)); /* round to either 0 or 1 */
color: oklch(
	from canvas var(--color-dark) var(--color-light) 
	calc-mix(var(--p), l2, l3) calc-mix(var(--p), h2, h3) calc-mix(var(--p), h2, h3)
);

Interpolation at a different rate per component

Example: Generating intermediate tints from an accent color and its lightest tint (assumes #11530 is accepted), where chroma typically interpolates at a different rate than other components:

:root {
--lightnesses: 0, 0.18, 0.24, 0.33, 0.4, 0.47, 0.57, 0.68, 0.76, 0.84, 0.92, 0.96, 100;

/* Tint that contains the accent color, can be reused across hues */
--accent-tint: round(progress(l, var(--lightnesses)), 5);
 
/* Progress of accent tints towards lightest tint, can be reused across hues */
--tint-progress-80: progress(80, var(--accent-tint), 95);

/* Math for figuring out components for tint 80, can be reused across hues */
--tint-80: var(--l-80) calc-mix(pow(var(--tint-progress-80), 2), c, c2) h;
}

.accented {
--color-80: lch(from var(--color) var(--color-95) var(--tint-80));
}

While this may seem complicated, it could be an immense help for #10948.
A design system with the average of 14 scales and 11 tints per hue (source) needs to define 14 * 11 = 154 custom properties, and to pass a color to a component or to change the color of a given element/subtree, one needs to set 11 custom properties. Being able to generate even just the intermediate ones would reduce these to just 3, a 72% reduction.

Implementing two color operations, e.g. blending modes

I've often needed operations like multiply on individual colors. Sure, if the need is widespread we could introduce an explicit function, but meanwhile, something as low-level like this allows authors implementing their own (and possibly shipping libraries with entire sets of custom properties for such operations):

--color-multiply: srgb-linear calc(r * r2) calc(g * g2) calc(b * b2);
background: color(from var(--color-1) var(--color-2) var(--color-multiply);

Once device-cmyk() actually ships, this can be used for overprint too:

--cmyk-overprint: clamp(none, c + c2, 100%) clamp(none, m + m2, 100%) clamp(none, y + y2, 100%) clamp(none, k + k2, 100%);
background: device-cmyk(from var(--color-1) var(--color-2) var(--cmyk-overprint));

Syntax

Assuming we have consensus that the problem needs solving, how do we solve it?

Some would argue it should be solved in color-mix(). I disagree. I think that would make for a much more cumbersome syntax, and is not easily extensible to >2 colors. It would also likely restrict use cases.

In #6937 we resolved to add color-extract() but that is a more general function, and would result in a lot of verbosity. Also, without restricting it to be used only within color functions, I suspect it could raise security concerns which would slow down implementation even more.

I think the nicest solution would be to extend RCS to support multiple colors by simply changing from <color> to from <color>+ in its grammar. This may even obliterate the need for color-extract() altogether — we should revisit it after to see if there are any remaining use cases for it.

Then the question becomes: how do we reference components of the 2nd, 3rd etc color? Some options are:

  1. Generate idents like c2, c3 etc. Or perhaps c-2, c-3 etc.
  2. I suspect some people may be more comfortable with a functional syntax like c(2) rather than supporting arbitrary idents. @fantasai and I are not huge fans of the extra parens (we already have too many!) but in the interest of moving the proposal forwards, I would not object to it. One advantage of it would be that it would support variables for the color index without depending on [css-values] A way to dynamically construct custom-ident and dashed-ident values #9141, though that's a small advantage since that's almost certainly shipping before this proposal. 😁
  3. Another option would be to pass the decision onto the user, by requiring them to name either the extra colors (and using that as a suffix) or the components. However, both @fantasai and I thought that this would add extra friction and the vast majority of cases would just be to add a numerical index like the one discussed in 1. We could ship a way to name these colors later, as an optional customization for nicer expressions, but IMO it should not be mandatory.

Sugar

A nice-to-have would be to also support a 1 version for the first color, i.e. c1/c-1/c(1) becomes an alias of c.

Another question is, how to deal with components out of bounds? E.g. c2 being specified when only one color is used. We could treat it as invalid and that would probably be fine. However, I think a better solution that allows more flexibility would be to resolve it against the color list we do have:

  • If only one color is specified, it resolves to the corresponding component of that color
  • If M (M > 1) colors are specified, getting a component of the n-th color (n > M) would be an alias to the corresponding component of the k-th color, where k = n mod M, i.e. extend the list of colors by repeating it.

This way, we can write expressions that account for up to e.g. N colors but fall back gracefully to fewer colors, which can be useful for use cases where we want alternating colors like e.g. charts, syntax highlighting, accented sections etc. See the contrast color use case above for an example of how this could help simplify code.

Layering

If it makes things easier for implementors, shipping a version that only supports up to 3 or even just 2 colors at first would still cover the vast majority of use cases (and the rest can be done by nesting multiple of these). Personally, I don't think I've ever encountered a use case that needed more than 3, actually.

Even in the long run, I think it's fine to set a relatively low upper bound (e.g. 16) for the number of colors that can be specified.

And obviously the sugar above could also be a Level 2 thing (as long as values out of bounds are treated as an error).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Wednesday afternoon
Development

No branches or pull requests

1 participant