Skip to content

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

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

Open
LeaVerou opened this issue Jan 18, 2025 · 3 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).

@tiaanl
Copy link
Contributor

tiaanl commented Jan 29, 2025

Adding symbols into the component name might cause confusion, e.g. lch(from chocolate lime calc(l-1-l-2) calc(c-2-c-1) calc(h-2-h)). The () might be better, but will still make it really hard to read, e.g. calc(c(1)+c(2)+c)

@astearns astearns moved this from Wednesday afternoon to Thursday afternoon in CSSWG January 2025 meeting Jan 29, 2025
@tabatkins
Copy link
Member

@tiaanl You need spaces around operators in math functions, so it woudl be calc(l-1 - l-2), etc. (This is precisely to avoid that sort of ambiguity.) That said, I do like just using plain letter and number, like c2 better; the dash makes the otherwise-tiny identifier look far more heavyweight than it needs to be.

@LeaVerou I agree something like this is useful. It's simply impossible to mix two colors in a mathematical way right now, which does indeed block several good use-cases.

I'm not the happiest about the readability of just listing several colors before the components, but I can't come up with a better syntax given the syntax we're starting from. I think it's probably the best we can do.

I definitely prefer just using c2/etc for the naming. No need to get complicated here; that syntax space should be safe to reserve for colors.

Another possibility, which I'm not super happy with, is to revive your older idea of a component-extractor function. That way, you can pull out the components you need from the second color into variables, then use them in RCS normally. It's more verbose, but even more low-level. In theory, it would also mean you could mix components from different color spaces, but I doubt that would ever be a meaningful operation.

@css-meeting-bot
Copy link
Member

The CSS Working Group just discussed [css-color-6] How to support color math involving more than one color?, and agreed to the following:

  • RESOLVED: Yes to this issue, let's get it in a spec and start work on it
The full IRC log of that discussion <kbabbitt> lea: a while back we resolved to adopt relative color syntax
<kbabbitt> ...
<kbabbitt> ... it's now in every browser, allows for math based on components of colors
<kbabbitt> ... use cases that involve more than one color keep cropping up
<hober> q+
<kbabbitt> ... e.g. mixing certain components from one color and other compionents from another
<kbabbitt> ... had an internal use case using components from one color and alpha from another
<kbabbitt> ... polyfilling other features can require components from multiple colors
<kbabbitt> ... e.g. two color operations like blending modes
<kbabbitt> ... it seems clear that there are use cases for this to be possible in some way
<kbabbitt> ... question is what's the best syntax
<kbabbitt> ... possibly extending color-mix but that could be a more complex syntax
<kbabbitt> ... not extensible to more than 2 colors
<kbabbitt> ... in another issue we resolved to add color-extract function
<kbabbitt> ... security and privacy implications so we might not see that anytime soon
<kbabbitt> ... it seems to me an extension on existing RCS is probably best way
<kbabbitt> ... but still how do we reference components of additional color?
<kbabbitt> ... could kick the ball down to authors and ask them
<kbabbitt> ... would prefer not to
<kbabbitt> ... if there's a user need we could do that later
<kbabbitt> ...ideally there should be default names like there are for first color
<kbabbitt> ... some might be comfortable with functional syntax eg. c(2)
<TabAtkins> q+
<kbabbitt> ... fantasai and I think there re too many parens already
<weinig> q+
<ChrisL> prefer c2 to c(2) or c-2
<kbabbitt> ... auto generating idents like c2 seems nicest way
<hober> q-
<kbabbitt> .... fine for initial vetsion to be limited in # of colors it supports
<emilio> q+
<kbabbitt> ... don't thinkm I've seen a quse case requiring more than 3
<kbabbitt> ... if impls want a pre defined max that's fine
<kbabbitt> ... other details like what if you're ref'ing an ident and you have fewer colors than that, proposals about that
<kbabbitt> ... can iron these out later if there's consensus on general direction
<astearns> ack TabAtkins
<kbabbitt> TabAtkins: agree with this and generally think lea's ideal case is right way to do it. +1
<astearns> ack weinig
<kbabbitt> weinig: one thing I couldn't quite understand: if you want to use channels from 2 different color spaces, would RCS support that?
<lea> qq+
<kbabbitt> ... usually RCS extracts channels in single color space
<kbabbitt> ... would we need to augment to define extraction?
<kizu> q+
<kbabbitt> lea: yes you can nest RCS, each time you convert to color space
<kbabbitt> weinig: so if you want lightness to multiply every rgb channel, extract lightness and thne put it in every channel?
<kbabbitt> lea: each op is done in one color space
<kbabbitt> ... base color can be a relative color
<kbabbitt> weinig: say you have a color you want its lightess from
<kbabbitt> ... and want to multiply every channel of an rgb color by that value
<kbabbitt> .... to brighten its intensity
<kbabbitt> lea: why not operate on lightness itself?
<kbabbitt> weinig: that's fair
<kbabbitt> lea: we can revisit color-extract later which would allow that
<kbabbitt> ... not sure there's enough cases but could revisit
<kbabbitt> weinig: your argument is strong
<ChrisL> In general, doing math on gamma-encoded rgb channel values is almost never useful
<kbabbitt> ... also: are there other areas of CSS where extracting parts of it and using those would be useful?
<kbabbitt> ... so that instead of color-extract we have a more generic form
<kbabbitt> ... to avoid color-mix to mix thing
<kbabbitt> ... if we were to go the extract route are there other potential use cases?
<kbabbitt> lea: good question, can't think of any offhand
<kbabbitt> weinig: don't see any downside to adding support for multiple colors
<kbabbitt> ... could do other things if we want
<astearns> ack lea
<Zakim> lea, you wanted to react to weinig
<astearns> ack emilio
<kbabbitt> emilio: my question was similar to weinig's
<kbabbitt> ...what color space is used if you have multiple
<kbabbitt> ...would you get components of each color in its own color space, and ... target?
<kbabbitt> lea: this is already defined for RCS
<kbabbitt> ... color converted to color space you're working in
<kbabbitt> emilio: color space doesn't depend on input, depends on function being used
<kbabbitt> lea: precisely
<kbabbitt> ... that's how RCS works already
<astearns> ack kizu
<kbabbitt> kizu: we need something like this
<kbabbitt> ...while we still want to have this in situ way of doing things
<kbabbitt> ... might also want color-extract
<kbabbitt> ... cases where it's difficult to do this in one function
<kbabbitt> lea: you can use CSS variables
<kbabbitt> kizu: can you assing custom prop with color component and then reuse?
<kbabbitt> lea: yes, doesn't resolve until used but could use custom prop for calculations
<kbabbitt> kizu: if you are not registering them you could do this
<kbabbitt> ... if we are a fan of color-extract for security reasons
<kbabbitt> ...one solution might be to do it only in custom functions that return a color
<lea> e.g. `--lighter: calc(l * 1.2); color: oklch(from var(--color) var(--lighter) c h);` works fine
<kbabbitt> s/are/aren't/
<kbabbitt> [crosstalk]
<weinig> q+
<astearns> ack weinig
<kbabbitt> weinig: what are the security/privacy concerns with extract color?
<kbabbitt> lea: right now you could paint certaing colors on a canvas and read them but...
<kbabbitt> weinig: could use gCS to read serialization
<kbabbitt> lea: this adds vector to CSS itself
<kbabbitt> ...instead of needing JS
<kbabbitt> ... minor point but could imagine people raising concerns
<kbabbitt> ... e.g. previous meeting accent-color had concerns
<lea> or even `--lighter: calc(l * 1.2) calc(c * 1.05); color: oklch(from var(--color) var(--lighter) h);`
<kbabbitt> weinig: they have to be resolved for gCS anyway
<kbabbitt> ... if we allow that, you can always find out channels yourself
<kbabbitt> lea: fair enough
<kbabbitt> ... fwiw even if we decide that extract-color is useful, in many cases it would be verbose, this is simpler
<kbabbitt> weinig: not objecting just wanted to know what security and privacy concerns were
<kbabbitt> astearns: shall we resolve on adding this to spec>
<kbabbitt> weinig: I feel we need more debate on syntax for getting components
<kbabbitt> ... or maybe a little more thought on if this concept of indexing into an array is something we're creating here
<kbabbitt> ...probably not the last time CSS will need indexing into an array of objects
<kbabbitt> ... coming up with a syntax we're OK with in future is useful
<kbabbitt> astearns: could have this proposal in spec with an issue sayting we need to think about component extraction
<kbabbitt> weinig: ok
<kbabbitt> astearns: Proposed: Yes to this issue, let's get it in a spec and start work on it
<ChrisL> +1
<kbabbitt> RESOLVED: Yes to this issue, let's get it in a spec and start work on it

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

No branches or pull requests

5 participants