Look at those nice, geometric shapes! RGB gives us a rainbow cube, while HSL and HWB (with their "polar" hue
channels) arrange those same colors into cylinders. The clean boundaries make it easy for us to know (mathematically) what colors are in gamut or out of gamut . In rgb()
we use values of 0-255
. Anything inside that range will be inside the cube, but if a channel goes below 0
or above 255
, we’re no longer inside the sRGB
gamut. In hsl()
and hwb()
the hue
coordinates can keep going around the circle without ever reaching escape velocity, but the saturation
, lightness
, whiteness
, and blackness
channels go cleanly from 0-1
or 0%-100%
. Again, anything outside that range is outside the color space.
But that simplicity comes with limitations. The most obvious is that monitors keep getting better. These days, many monitors can display colors beyond sRGB
, especially extending the range of bright greens available. If we simply extend our shapes with the new colors available, we’re no longer dealing with clean geometry!
The crisp edges and clean math of sRGB
formats were only possible because we knew exactly what colors could be displayed, and we arranged those colors to fit perfectly into a box. But human color perception is not so clear-cut, and it doesn’t align perfectly with the gamut of any monitors on the market. When we attempt to space all the same colors evenly based on human perception rather than simple math, we get an entirely different shape with swooping edges. This is the display-p3
gamut in oklch
space:
The practical difference is particularly noticeable when we compare colors of the same ‘lightness’ in hsl
vs oklch
. Humans perceive yellow hues as lighter than blues. By scaling them to fit in the same range, hsl
gives us a yellow that is much brighter than the blue:
Moving forward, there are two directions we could go with wide gamut colors:
Color formats that re-fit larger and larger gamuts into simple coordinates, stretching the colors to preserve clean, geometric boundaries.
Color formats that maintain their perceptually uniform spacing, without any regard for specific gamuts.
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 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 .
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.
In the same color()
function, we can also access the ‘device independent’ (and gamut-less) xyz
color spaces – often used as an international baseline for converting between different color models. I won’t get into white points here, but we can specify xyz-d65
(the default) explicitly, or use xyz-d50
instead.
Working outwards from xyz
, we get a number of new theoretically unbounded color formats – prioritizing perceptually uniform distribution over clean geometry. These are available in functions of their own, including lab()
(lightness
, a
, and b
) and lch()
(lightness
, chroma
, and hue
) along with the newer ‘ok’ versions of each – oklab()
and oklch()
. If you want the full history of these formats, Eric Portis has written a great explainer .
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 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.
Sass now accepts all the new CSS formats, and treats them as first-class colors that we can manipulate, mix, convert, and inspect. These functions are all available globally:
lab()
, oklab()
, lch()
, and oklch()
color()
using the sRGB
, srgb-linear
, display-p3
, a98-rgb
, prophoto-rgb
, rec2020
, xyz
, xyz-d65
, and xyz-d50
color spaces
hwb()
(Sass previously had a color.hwb()
function, which is now deprecated in favor of the global function)
The Sass color functions use the same syntax as the CSS functions, which means that a given color can be represented in a variety of different spaces. For example, these are all the same color:
Playground
SCSS Syntax@debug MediumVioletRed;
@debug #C71585;
@debug hsl ( 322.2 80.91% 43.14%) ;
@debug oklch ( 55.34% 0.2217 349.7) ;
@debug color ( display-p3 0.716 0.1763 0.5105) ;
Playground
Sass Syntax @debug MediumVioletRed
@debug #C71585
@debug hsl(322.2 80.91% 43.14%)
@debug oklch(55.34% 0.2217 349.7)
@debug color(display-p3 0.716 0.1763 0.5105)
Historically, both CSS and Sass would treat the different color-spaces as interchangeable . When all the color formats describe the same color gamut using the same underlying model, you can provide a color using hsl()
syntax, and the parser can eagerly convert it to rgb()
without risking any data loss. That’s no longer the case for modern color spaces.
In general, any color defined in a given space will remain in that space, and be emitted in that space. The space is defined by the function used, either one of the named spaced passed to color()
, or the function name (e.g. lab
for colors defined using the lab()
function).
However, the rgb
, hsl
, and hwb
spaces are considered "legacy spaces", and often get special handling for the sake of backwards compatibility. Legacy colors are still emitted in the most backwards-compatible format available. This matches CSS ’s own backwards-compatibility behavior. Colors defined using hex notation or CSS color names are also considered part of the legacy rgb
color space.
Sass provides a variety of tools for inspecting and working with these color spaces:
We can inspect the space of a color using color.space($color)
We can ask if the color is in a legacy space with color.is-legacy($color)
We can convert a color from one space to another using color.to-space($color, $space)
All of these functions are provided by the built-in Sass Color Module :
Playground
SCSS Syntax@use 'sass:color' ;
$brand : MediumVioletRed;
@debug color.space ( $brand ) ;
@debug color.is-legacy ( $brand ) ;
@debug color.to-space ( $brand , 'oklch' ) ;
@debug color.space ( $brand ) ;
@debug color.is-legacy ( $brand ) ;
Playground
Sass Syntax @use 'sass:color'
$brand : MediumVioletRed
@debug color.space($brand)
@debug color.is-legacy($brand)
@debug color.to-space($brand, 'oklch')
@debug color.space($brand)
@debug color.is-legacy($brand)
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:
Playground
SCSS Syntax@use 'sass:color' ;
$orange-rgb : #ff5f00;
$orange-oklch : oklch ( 68.72% 20.966858279% 41.4189852913deg) ;
@debug $orange-rgb == $orange-oklch ;
@debug color.same ( $orange-rgb , $orange-oklch ) ;
Playground
Sass Syntax @use 'sass:color'
$orange-rgb : #ff5f00
$orange-oklch : oklch(68.72% 20.966858279% 41.4189852913deg)
@debug $orange-rgb = = $orange-oklch
@debug color.same($orange-rgb, $orange-oklch)
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:
Playground
SCSS Syntax@use 'sass:color' ;
$brand : hsl ( 0 100% 25.1%) ;
@debug color.channel ( $brand , "lightness" ) ;
@debug color.channel ( $brand , "lightness" , $space : oklch) ;
Playground
Sass Syntax @use 'sass:color'
$brand : hsl(0 100% 25.1% )
@debug color.channel($brand, "lightness")
@debug color.channel($brand, "lightness", $space: oklch)
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 the hue
channel to be powerless. Changing its 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:
Playground
SCSS Syntax@use 'sass:color' ;
$gray : hsl ( 0 0% 60%) ;
@debug color.is-powerless ( $gray , "hue" ) ;
@debug color.is-powerless ( $gray , "lightness" ) ;
Playground
Sass Syntax @use 'sass:color'
$gray : hsl(0 0% 60% )
@debug color.is-powerless($gray, "hue")
@debug color.is-powerless($gray, "lightness")
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:
Playground
SCSS Syntax@use 'sass:color' ;
$brand : hsl ( none 100% 25.1%) ;
@debug color.is-missing ( $brand , "lightness" ) ;
@debug color.is-missing ( $brand , "hue" ) ;
Playground
Sass Syntax @use 'sass:color'
$brand : hsl(none 100% 25.1% )
@debug color.is-missing($brand, "lightness")
@debug color.is-missing($brand, "hue")
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.
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:
Playground
SCSS Syntax@use 'sass:color' ;
$brand : hsl ( 0 100% 25.1%) ;
@debug color.scale ( $brand , $lightness : 25%) ;
@debug color.scale ( $brand , $lightness : 25%, $space : oklch) ;
Playground
Sass Syntax @use 'sass:color'
$brand : hsl(0 100% 25.1% )
@debug color.scale($brand, $lightness: 25%)
@debug color.scale($brand, $lightness: 25%, $space: oklch)
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 – 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
:
Playground
SCSS Syntax@use 'sass:color' ;
@debug color.mix ( red, blue, 40%) ;
@debug color.mix ( red, blue, 40%, $method : lab) ;
@debug color.mix ( red, blue, 40%, $method : oklch longer hue) ;
Playground
Sass Syntax @use 'sass:color'
@debug color.mix(red, blue, 40%)
@debug color.mix(red, blue, 40%, $method: lab)
@debug color.mix(red, blue, 40%, $method: 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.
So what happens when you go outside the gamut of a given display? Browsers are still debating the details, but everyone agrees we have to display something :
Currently, browsers convert every color into red
, green
, and blue
channels for display. If any of those channels are too high or two low for a given screen, they get clamped at the highest or lowest value allowed. This is often referred to as ‘channel clipping’. It keeps the math simple, but it can have a weird effect on both the hue
and lightness
if some channels are clipped more than others.
The CSS specification says that preserving lightness
should be the highest priority, and provides an algorithm for reducing chroma
until the color is in gamut. That’s great for maintaining readable text, but it’s more work for browsers, and it can be surprising when colors suddenly lose their vibrance.
There’s been some progress on a compromise approach, reducing chroma
to get colors inside the rec2020
gamut, and clipping from there.
Since browser behavior is still unreliable, and some color spaces (cough oklch
) can easily launch us out of any available gamut, it can be helpful to do some gamut management in Sass.
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:
Playground
SCSS Syntax@use 'sass:color' ;
$extra-pink : color ( display-p3 0.951 0.457 0.7569) ;
@debug color.is-in-gamut ( $extra-pink ) ;
@debug color.is-in-gamut ( $extra-pink , $space : srgb) ;
Playground
Sass Syntax @use 'sass:color'
$extra-pink : color(display-p3 0.951 0.457 0.7569)
@debug color.is-in-gamut($extra-pink)
@debug color.is-in-gamut($extra-pink, $space: srgb)
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):
Playground
SCSS Syntax@use 'sass:color' ;
$extra-pink : oklch ( 90% 90% 0deg) ;
@debug color.to-gamut ( $extra-pink , srgb, clip) ;
@debug color.to-gamut ( $extra-pink , srgb, local-minde) ;
Playground
Sass Syntax @use 'sass:color'
$extra-pink : oklch(90% 90% 0deg)
@debug color.to-gamut($extra-pink, srgb, clip)
@debug color.to-gamut($extra-pink, srgb, local-minde)
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.
A number of existing functions only make sense for legacy colors, and so are being deprecated in favor of color-space-friendly functions like color.channel()
and color.adjust()
. Eventually these will be removed from Sass entirely, but all the same functionality is still available in the updated functions:
color.red()
color.green()
color.blue()
color.hue()
color.saturation()
color.lightness()
color.whiteness()
color.blackness()
adjust-hue()
saturate()
desaturate()
transparentize()
/fade-out()
opacify()
/fade-in()
lighten()
/darken()
We’ve added a migrator to automatically
convert these legacy functions to the color-space-friendly ones.
$ sass-migrator color --migrate-deps < path/to/style.scss>