w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.39k stars 644 forks source link

[css-color-5] `color-scale()` for interpolating across multiple colors #10034

Open LeaVerou opened 5 months ago

LeaVerou commented 5 months ago

During the discussion in #9992 it occurred to me that one of the things that could really help simplify the color-related design systems use cases would be a way to define a gradient line and pick a color on it.

Why?

Syntax

Option 1: Single function for both defining the scale, and selecting a color

<color-scale()> = color-scale ( <percentage> / <color-interpolation-method>?, <abstract-color-stop-list> )
<abstract-color-stop-list> =   <abstract-color-stop> , [ <abstract-color-hint>? , <abstract-color-stop> ]#
<abstract-color-stop> = <color> <percentage>?

Example usage:

--tints-green: white, var(--color-green), black;
--color-green-900: color-scale(90% / var(--tint-green));

This is basically modeled after linear-gradient() with the non relevant parts removed (lengths, to <whatever>, angles). It could also allow <1d-image> / stripes() to facilitate discrete scales.

The reason the percentage is separated from the rest with a / is to facilitate storing the actual scale part into variables and passing them around without having to worry about whether you need to specify a comma or not (depending on whether the scale has a <color-interpolation-method>).

Pros:

Option 2: Separate scale definition and color selection

This syntax decouples the scale from the color selection. It seems more conceptually sound, but also too many parens.

<color-scale> = color-scale ( <color-interpolation-method>?, <abstract-color-stop-list> )
<abstract-color-stop-list> =   <abstract-color-stop> , [ <abstract-color-hint>? , <abstract-color-stop> ]#
<abstract-color-stop> = <color> <percentage>?

<color-pick()> = color-pick(<percentage> of <color-scale>);

Example:

--tints-green: color-scale(white, var(--color-green), black);
--color-green-900: color-pick(90% of var(--tints-green));

the parens could be reduced if it would be possible to define tokens like:

<color-scale-color> = <percentage> of <color-scale>

Example:

--tints-green: color-scale(white, var(--color-green), black);
--color-green-900: 90% of var(--tints-green);

but I suspect @tabatkins will have a good reason to rule that out 😁

We could also make it a variant of color-mix():

<color-mix> := color-mix(<percentage> of <color-scale>)

Example:

--tints-green: color-scale(white, var(--color-green), black);
--color-green-900: color-mix(90% of var(--tints-green));

Though since conceptually we're not mixing anything, I don't think this is worth it.

More Examples

Yellow tints that skew oranger when darker

Option 1:

--color-yellow: oklch(88% 0.2 95);
--color-yellow-100: oklch(99% 0.03 100);
--color-yellow-900: oklch(40% 0.09 70);
--tints-yellow: var(--color-yellow-100), var(--color-yellow), var(--color-yellow-900);

--color-green-200: color-scale(20% / var(--tints-yellow));

Transparent variations of a base color

Option 1:

--color-neutral-a: var(--color-neutral), transparent;
--color-neutral-a-90: color-scale(90% / var(--color-neutral-a));

Option 2:

--color-neutral-a: color-scale(var(--color-neutral), transparent);
--color-neutral-a-90: 90% of var(--color-neutral-a));

Success/failure scales

This is super common to communicate varying levels of success/failure. There are two main forms: red - orange - yellow - green - dark green, or red - white - green. E.g. see screenshot from Coda’s conditional formatting:

image

Especially the red - orange - yellow - green scales almost always require manual correction, and cannot be done with a single 2 color interpolation (yes not even in polar spaces). With color-scale() they can be as simple as:

:root {
    --color-scale-bad-good: in oklch, var(--red), var(--orange), var(--yellow) 50%, var(--green) 90%, var(--dark-green);
}

.badge {
    background: color-scale(var(--percent-good) of var(--color-scale-bad-good));

    .great { --percent-good: 100% }
    .good { --percent-good: 80% }
    .ok { --percent-good: 60% }
    .fair { --percent-good: 40% } 
    .poor { --percent-good: 20% } 
    .terrible { --percent-good: 0% } 
}
svgeesus commented 5 months ago

I can see advantages (more generally useful) and disadvantages (cumbersome syntax) to the second option. I'm not sure which is the better option, 1 or 2.

The functionality seems very useful, clearly.

svgeesus commented 5 months ago

All the examples omit the optional percentage for stop position; it would be good to add an example that explicitly uses stop positioning.

LeaVerou commented 5 months ago

All the examples omit the optional percentage for stop position; it would be good to add an example that explicitly uses stop positioning.

The success-failure scale does.

tabatkins commented 5 months ago

As we noticed privately, I posted essentially the exact same idea just a few hours prior to yours (decent chance we overlapped in authoring the texts!) in https://github.com/w3c/csswg-drafts/issues/9992#issuecomment-1979797500.

Notable differences are:

  1. No implicit stop positions in my proposal; you do have to manually specify every stop. This is important for...
  2. Ability to generate a scale from an existing scale, adding/overriding stops in the input scale.

If stops can have an implicit position, then you have to rely on numeric precision to allow overriding to work. Like, given 0 red, orange, yellow, 100 green, if you wanted to override the orange stop you'd have to write calc(100 / 3) salmon and hope it worked.

LeaVerou commented 5 months ago

As I wrote in my reply today:

tabatkins commented 4 months ago

I'm sorry, I'm not sure I understand what most of these replies have to do with what I was saying. I suspect you're reading some additional stuff I didn't say into my post.

The design system user doing the overriding, e.g. the red 900 tint is too dark for my liking, I’ll make it a bit lighter. That is far lower priority.

In #9992 this seemed to be a relatively important part of the proposals for you. Why is it so much lower priority here?

I don't see why we'd bake the specific tint levels into the ramp. [...]

I'm not sure what this entire paragraph is in reference to.

From experience, having to adjust the other colors when inserting a color is incredibly annoying and one of the things I was trying to avoid with this proposal.

I'm not sure how what I said in my most recent reply has any bearing on this. Can you elaborate on what problem you're seeing here?

I do agree that having to match the exact percentage by precision is annoying, but we should not let complex cases get in the way of common ones. We can continue to discuss how to best design a syntax that allows overriding without this problem.

As I said, the end user being able to override a specific color choice from a design system's color scale seemed to be an important thing in your comments in #9992. What's different about this situation, where the color scale is encapsulated into a value rather than being implicit across multiple custom properties?

LeaVerou commented 2 months ago

I was reminded about this when I came across https://noahliebman.net/2024/04/recursion-in-the-stylesheet/ . These are the lengths authors have to go to today to achieve this.

Here’s my attempt at a green-yellow-orange-red scale without a preprocessor: https://codepen.io/leaverou/pen/ZENWMZr?editors=1100 Yay quadratic manual work!


After thinking about it some more, I don’t think overriding individual colors is something that should drive the design of this feature, and certainly not something that warrants complicating the base case. Authors can always override the individual colors used to generate the scale, and design systems can make sure to include enough extension points if they want to enable this.

I think we should keep the MVP as simple as possible. This means:

  1. A single function for picking the colors AND specifying the scale. Authors can abstract the scale away via CSS variables as they have been doing for color-mix(). Having two functions puts us in the awkward position of having to specify what color-scale() returns.
  2. Syntax that mirrors gradients and color stop positions as closely as possible. As a design goal, you should be able to throw a color scale into a linear-gradient(to right, ...) and see exactly the same colors you’d get from the scale.

Something like this:

<color-scale()> = color-scale ( at <percentage> <color-interpolation-method>?, <abstract-color-stop-list> )
<abstract-color-stop-list> =   <abstract-color-stop> , [ <abstract-color-hint>? , <abstract-color-stop> ]#
<abstract-color-stop> = <color> <percentage>{0,2}

Which would look like this:

--scale: in oklch, var(--color-green), var(--color-yellow) 40%, var(--color-orange) 60%, var(--color-red));
background: color-scale(at var(--progress) var(--scale));

I’m in two minds about whether the position you are selecting should be part of the preamble like above, slash separated, or even a separate argument.

One consideration is that while colors are possibly the most pressing need, length scales are also in wide usage (example). Whatever syntax we come up with should be consistent across different types of scales. I think the syntax above does lend itself nicely to calc-scale() (except hints can't be allowed there as there's no way to tell if they are hints or actual arguments).