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

LeaVerou has just created a new issue for https://github.com/w3c/csswg-drafts:

== [css-color-6] How to support color math involving more than one color? ==
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

```css
--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)

```css
--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](https://lea.verou.me/blog/2024/contrast-color/). 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.

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

```css
--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:
```css
--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`):

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

```css
: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](https://palettes.colorjs.io/)) 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):

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

```css
--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 #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).

Please view or discuss this issue at https://github.com/w3c/csswg-drafts/issues/11533 using your GitHub account


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

Received on Saturday, 18 January 2025 23:55:11 UTC