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-5] color-scale() for creating color scales via interpolation #10034

Open
LeaVerou opened this issue Mar 6, 2024 · 7 comments
Open
Labels
css-color-5 Color modification css-color-6

Comments

@LeaVerou
Copy link
Member

LeaVerou commented Mar 6, 2024

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?

  • As a primary use case, authors often need to define scales of colors with interim colors inserted to adjust the interpolation, and color-mix() is not very friendly to that. Super common example: the failure-success scale of red, orange, yellow, green. Yelp ratings are a popular application (and no, this is not just a simple polar interpolation between the endpoints):
    • image
    • image
    • image
    • image
    • image
    • image
  • Design systems could then define color scales for tints or semi-transparent variants and pass them around in variables, which would make functions a much more appealing solution for actually selecting points on those scales.
  • It makes interpolation with arbitrary manual interim points much easier, without requiring special overfit syntax in variable groups to cater to this.
  • The scale specification should be compatible with gradient color stops so that authors can debug them by simply throwing them into a linear-gradient().

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:

  • By passing a list of arguments around, these can produce both a color scale and various types of gradients (without gradients having to be extended in any way)
  • Color stop list could even be extended by adding more stops on either side
    Cons:
  • Scale variables don't make sense by themselves, as they're just a comma-separated list of colors.

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% } 
}
@LeaVerou LeaVerou added css-color-5 Color modification css-color-6 labels Mar 6, 2024
@svgeesus
Copy link
Contributor

svgeesus commented Mar 6, 2024

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
Copy link
Contributor

svgeesus commented Mar 6, 2024

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

@LeaVerou
Copy link
Member Author

LeaVerou commented Mar 6, 2024

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
Copy link
Member

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 #9992 (comment).

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
Copy link
Member Author

LeaVerou commented Mar 7, 2024

As I wrote in my reply today:

  • Wrt overridding the interpolation, there are two use cases here:
    • The design system author doing the overriding, e.g. to make darker yellows orangish, to spread out lighter tints etc. I think that's the bigger use case around overriding, and an inline function caters to that just fine, as it's defined by the design system author.
    • 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.
  • 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 be introducing a new alternative color stop syntax, when we already have a color stop syntax for exactly this very thing, which also means it's much harder to generate a gradient from the color scale (either as actual output, or for debugging)
  • 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 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.

@tabatkins
Copy link
Member

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
Copy link
Member Author

LeaVerou commented May 21, 2024

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).

@LeaVerou LeaVerou changed the title [css-color-5] color-scale() for interpolating across multiple colors [css-color-5] color-scale() for creating color scales via interpolation Sep 15, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
css-color-5 Color modification css-color-6
Projects
None yet
Development

No branches or pull requests

3 participants