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

First draft, color space announcement #1170

Merged
merged 56 commits into from
Sep 12, 2024
Merged
Changes from 5 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
7c1a4ce
Bump rollup from 4.18.0 to 4.19.0 (#1119)
dependabot[bot] Jul 22, 2024
1179f04
Bump @types/lodash from 4.17.5 to 4.17.7 (#1118)
dependabot[bot] Jul 22, 2024
cd3f3f0
Bump @typescript-eslint/eslint-plugin from 7.16.0 to 7.17.0 (#1117)
dependabot[bot] Jul 22, 2024
405e43e
Bump @babel/core from 7.24.7 to 7.24.9 (#1110)
dependabot[bot] Jul 22, 2024
6159757
Bump @babel/preset-env from 7.24.7 to 7.24.8 (#1109)
dependabot[bot] Jul 22, 2024
d3aabb9
Add a blog post indicating that Node Sass is end-of-life (#1120)
nex3 Jul 24, 2024
d68caec
Link to the CSSWG thread on mixed decls (#1113)
nex3 Jul 25, 2024
df2317a
Document Embedded Dart Sass (#1121)
nex3 Jul 26, 2024
724242a
Bump @typescript-eslint/eslint-plugin from 7.17.0 to 7.18.0 (#1127)
dependabot[bot] Jul 29, 2024
c323f02
Bump @types/markdown-it from 14.1.1 to 14.1.2 (#1126)
dependabot[bot] Jul 29, 2024
a4eab7b
Bump typescript from 5.4.5 to 5.5.4 (#1123)
dependabot[bot] Jul 29, 2024
2d4991a
Bump @babel/preset-env from 7.24.8 to 7.25.0 (#1124)
dependabot[bot] Jul 29, 2024
57745ef
Bump semver from 7.6.2 to 7.6.3 (#1125)
dependabot[bot] Jul 30, 2024
d5af2c5
Add mixed-decls.md breaking change to the breaking changes index (#1128)
Goodwine Jul 31, 2024
2d6ac2e
Fix link reference (#1129)
ntkme Aug 5, 2024
e9747f6
Bump prettier from 3.3.2 to 3.3.3 (#1134)
dependabot[bot] Aug 6, 2024
17f92ac
Bump rollup from 4.19.0 to 4.20.0 (#1133)
dependabot[bot] Aug 6, 2024
2def2c8
Bump @babel/preset-env from 7.25.0 to 7.25.3 (#1132)
dependabot[bot] Aug 6, 2024
b201e7b
Bump jquery-ui from 1.13.3 to 1.14.0 (#1130)
dependabot[bot] Aug 6, 2024
0acfc82
Bump @typescript-eslint/eslint-plugin from 7.18.0 to 8.0.1 (#1131)
dependabot[bot] Aug 6, 2024
da0841c
Fix grammatical error in syntax/index.md (#1142)
getsnoopy Aug 16, 2024
9de346a
Bump @11ty/eleventy-plugin-rss from 2.0.1 to 2.0.2 (#1141)
dependabot[bot] Aug 19, 2024
5ab8028
Bump eslint-plugin-prettier from 5.1.3 to 5.2.1 (#1139)
dependabot[bot] Aug 19, 2024
9b99927
Bump @typescript-eslint/eslint-plugin from 8.0.1 to 8.1.0 (#1138)
dependabot[bot] Aug 19, 2024
fbc4170
Bump immutable from 4.3.6 to 4.3.7 (#1137)
dependabot[bot] Aug 19, 2024
374e59e
Bump truncate-html from 1.1.1 to 1.1.2 (#1147)
dependabot[bot] Aug 21, 2024
12ac816
Bump rollup from 4.20.0 to 4.21.0 (#1145)
dependabot[bot] Aug 21, 2024
c2c5a9c
Bump @typescript-eslint/eslint-plugin from 8.1.0 to 8.2.0 (#1144)
dependabot[bot] Aug 21, 2024
470d4d0
Bump liquidjs from 10.14.0 to 10.16.3 (#1146)
dependabot[bot] Aug 21, 2024
ec99237
Fix brace error in forward documentation (#1149)
nickedelenbos Aug 22, 2024
2856181
Bump liquidjs from 10.16.3 to 10.16.4 (#1157)
dependabot[bot] Aug 26, 2024
7248af0
Bump @babel/preset-env from 7.25.3 to 7.25.4 (#1154)
dependabot[bot] Aug 26, 2024
2df4a2a
Bump rollup from 4.21.0 to 4.21.1 (#1155)
dependabot[bot] Aug 26, 2024
7762303
Bump markdown-it-anchor from 9.0.1 to 9.1.0 (#1156)
dependabot[bot] Aug 26, 2024
ed7646c
Bump @types/jqueryui from 1.12.22 to 1.12.23 (#1158)
dependabot[bot] Aug 26, 2024
4a7085b
Bump @babel/core from 7.24.9 to 7.25.2 (#1163)
dependabot[bot] Sep 3, 2024
f6dabbc
Bump liquidjs from 10.16.4 to 10.16.7 (#1162)
dependabot[bot] Sep 3, 2024
7dd41bc
Bump rollup from 4.21.1 to 4.21.2 (#1161)
dependabot[bot] Sep 3, 2024
96b9dfc
Bump @types/node from 16.18.101 to 16.18.106 (#1160)
dependabot[bot] Sep 3, 2024
51f38d5
Bump @typescript-eslint/eslint-plugin from 8.2.0 to 8.4.0 (#1159)
dependabot[bot] Sep 3, 2024
85bddda
Cut a release for a new Dart Sass version
sassbot Sep 3, 2024
795bb50
Document the `meta.feature-exists` deprecation (#1148)
nex3 Sep 4, 2024
7b13797
[Playground] Default code contents, Indented formatting (#1164)
jamesnw Sep 5, 2024
4fad520
Use the same TypeScript style as other Sass packages (#1165)
nex3 Sep 5, 2024
a49d459
Re-enable eslint for source/assets/js (#1168)
nex3 Sep 6, 2024
753dee8
First draft, color space announcement
mirisuzanne Sep 9, 2024
acabf7f
Include srgb images
mirisuzanne Sep 9, 2024
0e38157
Finalize images
mirisuzanne Sep 9, 2024
3d6f27e
Merge branch 'color-4' into color-space-post
mirisuzanne Sep 12, 2024
45e3a84
Apply suggestions from code review
mirisuzanne Sep 12, 2024
87188ec
Address specific review comments
mirisuzanne Sep 12, 2024
74caadc
Include skip-to-features link
mirisuzanne Sep 12, 2024
946e742
All examples in example syntax
mirisuzanne Sep 12, 2024
01c8036
Sneaky semi-colons trying to mess up my post
mirisuzanne Sep 12, 2024
ba39578
Use 0% saturation, and consistent gray spelling
mirisuzanne Sep 12, 2024
e48db0e
Update source/blog/042-wide-gamut-colors-in-sass.md
mirisuzanne Sep 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 175 additions & 82 deletions source/blog/042-wide-gamut-colors-in-sass.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ I should clarify. Wide gamut CSS color formats like `oklch(…)` and `color(disp

Often, that's all we need. When Cascade Layers and Container Queries rolled out in browsers, there was nothing more for Sass to do. But the new CSS color formats are a bit different. Since colors are a first-class data type in Sass, we don't always want to pass them along *as-is*. We often want to manipulate and manage colors before they go to the browser.

Already know all about color spaces? [Skip ahead to the new Sass features](#css-color-functions-in-sass)!

## The color format trade-off

CSS has historically been limited to `sRGB` color formats, which share two main features:
Expand Down Expand Up @@ -62,7 +64,7 @@ Moving forward, there are two directions we could go with wide gamut colors:

On the one hand, clean boundaries allow us to easily stay inside the range of available colors. Without those boundaries, it would be easy to *accidentally* request colors that aren't even physically possible. On the other hand, we expect these colors to be *perceived* by *other humans* -- and we need to make things *look* consistent, with enough contrast to be readable.

The [CSS Color Module Level 4](https://www.w3.org/TR/css-color-4/) defines a number of new CSS color formats. Some of them maintain geometric access to specific color spaces. The `hwb()` function has been around for several years now, and defines `sRGB` colors using `hue`, `whiteness`, and `blackness` channels. It's an interesting format, and [I've written about it before](https://www.miriamsuzanne.com/2022/06/29/hwb-clamping/).
The [CSS Color Module Level 4](https://www.w3.org/TR/css-color-4/) defines a number of new CSS color formats. Some of them maintain geometric access to specific color spaces. Like the more familiar `rgb()` and `hsl()` functions, the newer `hwb()` function still describes colors in the `sRGB` gamut, using `hue`, `whiteness`, and `blackness` channels. It's an interesting format, and [I've written about it before](https://www.miriamsuzanne.com/2022/06/29/hwb-clamping/).

The rest of the gamut-bounded spaces are available using the `color(<space> <3-channels> / <alpha>)` function. Using that syntax we can define colors in `sRGB`, `srbg-linear`, `display-p3` (common for modern monitors), `a98-rgb`, `prophoto-rgb`, and `rec2020`. Each of these maps the specified gamut onto a range of (cubic) coordinates from `0-1` or `0%-100%`. Nice and clean.

Expand All @@ -75,7 +77,7 @@ Working outwards from `xyz`, we get a number of new *theoretically unbounded* co
For the color experts, it's great to have all this flexibility. For the rest of us, there are a few stand-out formats:

- `color(display-p3 …)` provides access to a wider gamut of colors, which are available on many modern displays, while maintaining a clear set of gamut boundaries.
- `oklch(…)` is the most intuitive and perceptually uniform space to work in, a newer alternative to `hsl(…)` -- `chroma` is very similar to `saturation`. But there are no guard rails here, and it's easy to end up outside the gamuts that any screen can display, or even outside the realm of physical reality.
- `oklch(…)` is the most intuitive and perceptually uniform space to work in, a newer alternative to `hsl(…)` -- `chroma` is very similar to `saturation`. But there are few guard rails here, and it's easy to end up outside the gamuts that any screen can possibly display. The coordinate system is still describing a cylinder, but the edges of human perception and display technology don't map neatly into that space.
- For transitions and gradients, if we want to go directly between hues (instead of going around the color wheel), `oklab(…)` is a good linear option. Usually, a transition or gradient between two in-gamut colors will stay in gamut -- but we can't always rely on that when we're dealing with extremes of saturation or lightness.

## CSS color functions in Sass
Expand Down Expand Up @@ -118,109 +120,182 @@ Sass provides a variety of tools for inspecting and working with these color spa

All of these functions are provided by the built-in [Sass Color Module](https://sass-lang.com/documentation/modules/color/):

```scss
@use 'sass:color';
$brand: MediumVioletRed;
{% codeExample 'color-fns', false %}
@use 'sass:color';
$brand: MediumVioletRed;

// results: rgb, true
@debug color.space($brand);
@debug color.is-legacy($brand);

// results: rgb, true
$initial: color.space($brand);
$is-legacy: color.is-legacy($brand);
// result: oklch(55.34% 0.2217 349.7)
@debug color.to-space($brand, 'oklch');

// result: oklch(55.34% 0.2217 349.7)
$converted: color.to-space($brand, 'oklch');
// results: oklch, false
@debug color.space($brand);
@debug color.is-legacy($brand);
===
@use 'sass:color'
$brand: MediumVioletRed

// results: oklch, false
$new-space: color.space($brand);
$is-legacy: color.is-legacy($brand);
```
// results: rgb, true
@debug color.space($brand)
@debug color.is-legacy($brand)

// result: oklch(55.34% 0.2217 349.7)
@debug color.to-space($brand, 'oklch')

// results: oklch, false
@debug color.space($brand)
@debug color.is-legacy($brand)
{% endcodeExample %}

Once we convert a color between spaces, we no longer consider those colors to be *equal*. But we can ask if they would render as 'the same' color, using the `color.same()` function:

```scss
@use 'sass:color';
$orange-rgb: #ff5f00;
$orange-oklch: oklch(68.72% 20.966858279% 41.4189852913deg);
{% codeExample 'color-fns', false %}
@use 'sass:color';
$orange-rgb: #ff5f00;
$orange-oklch: oklch(68.72% 20.966858279% 41.4189852913deg);

// result: false
@debug $orange-rgb == $orange-oklch;

// result: true
@debug color.same($orange-rgb, $orange-oklch);
===
@use 'sass:color'
$orange-rgb: #ff5f00
$orange-oklch: oklch(68.72% 20.966858279% 41.4189852913deg)

// result: false
$equal: $orange-rgb == $orange-oklch;
// result: false
@debug $orange-rgb == $orange-oklch

// result: true
$same: color.same($orange-rgb, $orange-oklch);
```
// result: true
@debug color.same($orange-rgb, $orange-oklch)
{% endcodeExample %}

We can inspect the individual channels of a color using `color.channel()`. By default, it only supports channels that are available in the color's own space, but we can pass the `$space` parameter to return the value of the channel value after converting to the given space:

```scss
@use 'sass:color';
$brand: hsl(0 100% 25.1%);
{% codeExample 'color-fns', false %}
@use 'sass:color';
$brand: hsl(0 100% 25.1%);

// result: 25.1%
$hsl-lightness: color.channel($brand, "lightness");
// result: 25.1%
@debug color.channel($brand, "lightness");

// result: 37.67%
$oklch-lightness: color.channel($brand, "lightness", $space: oklch);
```
// result: 37.67%
@debug color.channel($brand, "lightness", $space: oklch);
===
@use 'sass:color'
$brand: hsl(0 100% 25.1%)

// result: 25.1%
@debug color.channel($brand, "lightness")

// result: 37.67%
@debug color.channel($brand, "lightness", $space: oklch)
{% endcodeExample %}

CSS has also introduced the concept of 'powerless' and 'missing' color channels. For example, an `hsl` color with `0%` saturation will *always be grayscale*. In that case, we can consider both the `hue` and `saturation` channels to be powerless. Changing their value won't have any impact on the resulting color. Sass allows us to ask if a channel is powerless using the `color.is-powerless()` function:
mirisuzanne marked this conversation as resolved.
Show resolved Hide resolved

{% codeExample 'color-fns', false %}
@use 'sass:color';
$gray: hsl(0 0% 60%);

CSS has also introduced the concept of 'powerless' and 'missing' color channels. For example, an `hsl` color with `100%` lightness will *always be white*. In that case, we can consider both the `hue` and `saturation` channels to be powerless. Changing their value won't have any impact on the resulting color. Sass allows us to ask if a channel is powerless using the `color.is-powerless()` function:
// result: true, because saturation is 0
@debug color.is-powerless($gray, "hue");

```scss
@use 'sass:color';
$grey: hsl(0 0% 60%);
// result: false
@debug color.is-powerless($gray, "lightness");
===
@use 'sass:color'
$gray: hsl(0 0% 60%)

// result: true, because saturation is 0
$hue-powerless: color.is-powerless($grey, "hue");
// result: true, because saturation is 0
@debug color.is-powerless($gray, "hue")

// result: false
$hue-powerless: color.is-powerless($grey, "lightness");
```
// result: false
@debug color.is-powerless($gray, "lightness")
{% endcodeExample %}

Taking that a step farther, CSS also allows us to explicitly mark a channel as 'missing' or unknown. That can happen automatically if we convert a color like `gray` into a color space like `oklch` -- we don't have any information about the `hue`. We can also create colors with missing channels explicitly by using the `none` keyword, and inspect if a color channel is missing with the `color.is-missing()` function:

```scss
@use 'sass:color';
$brand: hsl(none 100% 25.1%);
{% codeExample 'color-fns', false %}
@use 'sass:color';
$brand: hsl(none 100% 25.1%);

// result: false
$missing-lightness: color.is-missing($brand, "lightness");
// result: false
@debug color.is-missing($brand, "lightness");

// result: true
$missing-hue: color.is-missing($brand, "hue");
```
// result: true
@debug color.is-missing($brand, "hue");
===
@use 'sass:color'
$brand: hsl(none 100% 25.1%)

// result: false
@debug color.is-missing($brand, "lightness")

nex3 marked this conversation as resolved.
Show resolved Hide resolved
// result: true
@debug color.is-missing($brand, "hue")
{% endcodeExample %}

Like CSS, Sass maintains missing channels where they can be meaningful, but treats them as a value of `0` when a channel value is required.

## Manipulating Sass colors

The existing `color.scale()`, `color.adjust()`, and `color.change()` functions will continue to work as expected. By default, all color manipulations are performed *in the space provided by the color*. But we can now also specify an explicit color space for transformations:

```scss
@use 'sass:color';
$brand: hsl(0 100% 25.1%);
{% codeExample 'color-fns', false %}
@use 'sass:color';
$brand: hsl(0 100% 25.1%);

// result: hsl(0 100% 43.8%)
@debug color.scale($brand, $lightness: 25%);

// result: hsl(5.76 56% 45.4%)
@debug color.scale($brand, $lightness: 25%, $space: oklch);
===
@use 'sass:color'
$brand: hsl(0 100% 25.1%)

// result: hsl(0 100% 43.8%)
$hsl-lightness: color.scale($brand, $lightness: 25%);
// result: hsl(0 100% 43.8%)
@debug color.scale($brand, $lightness: 25%)

// result: hsl(5.76 56% 45.4%)
$oklch-lightness: color.scale($brand, $lightness: 25%, $space: oklch);
```
// result: hsl(5.76 56% 45.4%)
@debug color.scale($brand, $lightness: 25%, $space: oklch)
{% endcodeExample %}

Note that the returned color is still returned in the original color space, even when the adjustment is performed in a different space. That way we can start to use more advanced color spaces like `oklch` where they are useful, without necessarily relying on browsers to support those formats.

The existing `color.mix()` function will also maintain existing behavior *when both colors are in legacy color spaces*. Legacy mixing is always done in `rgb` space. We can opt into other mixing techniques using the new `$method` parameter, which is designed to match the CSS specification for describing [interpolation methods](https://www.w3.org/TR/css-color-4/#interpolation-space) – used in CSS gradients, filters, animations, and transitions as well as the new CSS `color-mix()` function.

For legacy colors, the method is optional. But for non-legacy colors, a method is required. In most cases, the method can simply be a color space name. But when we're using a color space with "polar hue" channel (such as `hsl`, `hwb`, `lch`, or `oklch`) we can also specify the *direction* we want to move around the color wheel: `shorter hue`, `longer hue`, `increasing hue`, or `decreasing hue`:

```scss
@use 'sass:color';
{% codeExample 'color-fns', false %}
@use 'sass:color';

// result: #660099
@debug color.mix(red, blue, 40%);

// result: rgb(176.2950613593, -28.8924497904, 159.1757183525)
@debug color.mix(red, blue, 40%, $method: lab);

// result: rgb(-129.55249236, 149.0291922672, 77.9649510422)
@debug color.mix(red, blue, 40%, $method: oklch longer hue);
===
@use 'sass:color'

// result: #660099
@debug color.mix(red, blue, 40%)

// result: #660099
$legacy: color.mix(red, blue, 40%);
// result: rgb(176.2950613593, -28.8924497904, 159.1757183525)
@debug color.mix(red, blue, 40%, $method: lab)

// result: ???
$lab: color.mix(red, blue, 40%, $method: lab);
// result: rgb(-129.55249236, 149.0291922672, 77.9649510422)
@debug color.mix(red, blue, 40%, $method: oklch longer hue)
{% endcodeExample %}

// result: ???
$oklch-longer: color.mix(red, blue, 40%, oklch longer hue);
```

In this case, the first color in the mix is considered the "origin" color. Like the other functions above, we can use different spaces for mixing, but the result will always be returned in that origin color space.

Expand All @@ -236,31 +311,49 @@ Since browser behavior is still unreliable, and some color spaces (*cough* `oklc

We can use `color.is-in-gamut()` to test if a particular color is in a given gamut. Like our other color functions, this will default to the space the color is defined in, but we can provide a `$space` parameter to test it against a different gamut:

```scss
@use 'sass:color';
$extra-pink: color(display-p3 0.951 0.457 0.7569);
{% codeExample 'color-fns', false %}
@use 'sass:color';
$extra-pink: color(display-p3 0.951 0.457 0.7569);

// result: true, for display-p3 gamut
@debug color.is-in-gamut($extra-pink);

// result: false, for srgb gamut
@debug color.is-in-gamut($extra-pink, $space: srgb);
===
@use 'sass:color'
$extra-pink: color(display-p3 0.951 0.457 0.7569)

// result: true, for display-p3 gamut
$in-p3: color.is-in-gamut($extra-pink);
// result: true, for display-p3 gamut
@debug color.is-in-gamut($extra-pink)

// result: false, for srgb gamut
$in-srgb: color.is-in-gamut($extra-pink, $space: srgb);
```
// result: false, for srgb gamut
@debug color.is-in-gamut($extra-pink, $space: srgb)
{% endcodeExample %}

We can also use the `color.to-gamut()` function to explicitly move a color so that it is in a particular gamut. Since there are several options on the table, and no clear sense what default CSS will use long-term, this function currently requires an explicit `$method` parameter. The current options are `clip` (as is currently applied by browsers) or `local-minde` (as is currently specified):

```scss
@use 'sass:color';
$extra-pink: color(display-p3 0.951 0.457 0.7569);
{% codeExample 'color-fns', false %}
@use 'sass:color';
$extra-pink: oklch(90% 90% 0deg);

// result: oklch(68.3601568298% 0.290089749 338.3604392249deg)
@debug color.to-gamut($extra-pink, srgb, clip);

// result: ???
$clip-to-srgb: color.to-gamut($extra-pink, srgb, clip);
// result: oklch(88.7173946522% 0.0667320674 355.3282956627deg)
@debug color.to-gamut($extra-pink, srgb, local-minde);
===
@use 'sass:color'
$extra-pink: oklch(90% 90% 0deg)

// result: oklch(68.3601568298% 0.290089749 338.3604392249deg)
@debug color.to-gamut($extra-pink, srgb, clip)

// result: ???
$clip-to-srgb: color.to-gamut($extra-pink, srgb, local-minde);
```
// result: oklch(88.7173946522% 0.0667320674 355.3282956627deg)
@debug color.to-gamut($extra-pink, srgb, local-minde)
{% endcodeExample %}

All legacy and RGB-style spaces represent bounded gamuts of color. Since mapping colors into gamut is a lossy process, it should generally be left to browsers or done with caution. For that reason, out-of-gamut channel values are maintained by Sass whenever possible, even when converting into gamut-bounded color spaces. The only exception is that `hsl` and `hwb` color spaces are not able to express out-of-gamut color, so converting colors into those spaces will gamut-map the colors as well.
All legacy and RGB-style spaces represent bounded gamuts of color. Since mapping colors into gamut is a lossy process, it should generally be left to browsers or done with caution. For that reason, out-of-gamut channel values are maintained by Sass, even when converting into gamut-bounded color spaces.

Legacy browsers require colors in the `srgb` gamut. However, most modern displays support the wider `display-p3` gamut.

Expand Down
Loading