惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

G
Google Developers Blog
Apple Machine Learning Research
Apple Machine Learning Research
小众软件
小众软件
Recent Announcements
Recent Announcements
阮一峰的网络日志
阮一峰的网络日志
IT之家
IT之家
A
About on SuperTechFans
量子位
Engineering at Meta
Engineering at Meta
B
Blog
The Cloudflare Blog
博客园 - 【当耐特】
Hugging Face - Blog
Hugging Face - Blog
Y
Y Combinator Blog
J
Java Code Geeks
D
DataBreaches.Net
aimingoo的专栏
aimingoo的专栏
T
Tailwind CSS Blog
H
Help Net Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
V
V2EX
Stack Overflow Blog
Stack Overflow Blog
C
Check Point Blog
酷 壳 – CoolShell
酷 壳 – CoolShell

Miriam Eric Suzanne

Butter bells, fresh from the kiln Butter bells, fresh from the kiln Butter bells, fresh from the kiln I had to look it up I Laser-cut pottery throwing gauge Tech continues to be political Aggregating my distributed self A web component for CodePen embeds? We don Eleventy buckets & cascade layers A slash-why proposal User styles on the web Custom element, two ways New year, same (terrible) Mia CSS @scope Reclaiming my time Cascade Layers Javascript automation on Mac Personal Histories Ancient Web Browsers Critical CSS? Not So Fast! CSS tie-dye gradient backgrounds Personal website redesign Request for Comments: Sass Color Spaces A long-term plan for logical properties? Container queries in browsers! I never let things be small A whole cascade of layers This content won
The gray areas of HWB color
2022-06-29 · via Miriam Eric Suzanne

Working on Sass support for color spaces, I ran into a question about the proper handling of hwb() colors. That lead me down a rabbit hole, exploring the edges of hwb (and powerless color channels) in CSS.

Intro to HWB colors

The HWB format has been around in design tools for a long time, but it is fairly recent on the web. It works by taking a fully-saturated hue, and then mixing in whiteness and blackness to achieve less-saturated lighter and darker variants. For any given hue, you can visualize a triangle with a fully saturated color in one corner, and then white and black in the other corners. This is how the old Chrome color picker used to show HWB color:

color picker with an outer wheel for selecting hue, and an inner triangle for selecting relative amounts of white and black

The side of the triangle across from our saturated-hue corner is entirely grayscale – the line at which blackness and whiteness together reach 100%, and wash out any contribution from the hue.

I think it’s a pretty good model for adjusting colors. Mixing with white or black is often the best way to ‘lighten’ or ‘darken’ a color. Under the hood, HWB still relies on RGB color math (which is not perceptually uniform) and the sRGB color space (which is fairly limited) – but that’s also true of the more popular HSL format.

HWB in CSS

The hwb() function in CSS expects a hue angle (usually in degrees) and two percentages representing the amount of ‘whiteness’ and ‘blackness’ to mix in:

/* a bright cyan with only slight whiteness & blackness */
html { background: hwb(180deg 5% 10%); }

Because we’re using numbers here instead of a triangle, it’s possible for the combined whiteness and blackness to overshoot 100%. If you lay the results out in a table, the triangle is still visible – where the combined whiteness & blackness are less than or equal to 100% – but we also see a reflected grayscale triangle where the combined values are greater than 100%:

w0% w10% w20% w30% w40% w50% w60% w70% w80% w90% w100% b0% b10% b20% b30% b40% b50% b60% b70% b80% b90% b100%
An HWB table of colors using 180deg hue, incrementing whiteness and blackness from 0 to 100%.

That extended grayscale triangle is useless. There’s nothing meaningful to find out there in the wilderness beyond 100% grayness – so CSS just scales whiteness and blackness down until they fit the triangle again. The rendered color hwb(0deg 50% 50%) is identical to hwb(0deg 60% 60%) and hwb(0deg 100% 100%) – because all of them have the same relative mix of white and black washing out the hue.

‘Powerless’ & ‘missing’ channels

It may be a bit strange that we have access to so many duplicate grays in HWB, but there tends to be some duplication in any color format using hue angles. In HSL colors, for example, a lightness of either 0% or 100% will wash out both hue and saturation. The rendered color hsl(0deg 20% 100%) is identical to hsl(0deg 60% 100%) and hsl(0deg 100% 100%). Still, that’s a much smaller portion of the table:

s0% s10% s20% s30% s40% s50% s60% s70% s80% s90% s100% l0% l10% l20% l30% l40% l50% l60% l70% l80% l90% l100%
An HSL table of colors using 180deg hue, incrementing lightness and saturation from 0 to 100%.

In both HWB & HSL colors, we can describe white and black and a full scale of grays using any hue we want. It doesn’t matter what hue we provide in either table – the grayscale cells will remain the same. In those cells, hue has become ‘powerless’.

CSS Color Module Level 4 defines a number of situations that might result in powerless color components:

  • hsl:
    • If the saturation value is 0%, then the hue channel is powerless.
    • If the lightness value is either 0% or 100%, then both the hue and saturation values are powerless.
  • hwb:
    • If the combined whiteness and blackness values (after normalization) are equal to 100%, then the hue channel is powerless.
  • lab/oklab:
    • If the lightness value is 0%, then both the a and b channels are powerless.
    • The current spec has an open issue to determine if high values of lightness (whites) should make the a and b values powerless, but there is no clear upper boundary to rely on.
  • lch/oklch:
    • If the chroma value is 0%, then the hue channel is powerless.
    • If the lightness value is 0%, then both the hue and chroma channels are powerless.
    • The current spec has an open issue to determine if high values of lightness (whites) should make the hue and chroma values powerless, but there is no clear upper boundary to rely on.

For the most part, powerless channels are a harmless result of representing colors through math. But sometimes, when we want to mix or adjust colors, a powerless component can show up suddenly and cause issues. Imagine a gradient from hsl(0deg 100% 0%) (black, with a powerless red hue) through hsl(180deg 100% 50%) (a bright cyan). If we do naive math to get from one to the other – adjusting each channel along the way – we have to go through a full range of hues just to get from black to cyan:

Lucky for us, browsers don’t generally render gradients using naive HSL math:

Currently browsers convert everything to sRGB before mixing. Gradients in RGB can still get muddy at times, but there are no ‘powerless’ components – gray scales involve an equal mix of all three channels – so that’s a separate issue.

The new spec provides a more explicit way to ‘leave out’ a powerless channel, so that it can’t cause issues like this. We can use the none keyword in place of a value to notate a ‘missing’ channel:

/* full black, the hue and saturation are powerless */
html { background: hsl(none none 0%); }

Browsers are instructed to use none when they are converting from one format to another, if the conversion results in a powerless channel. Rather than converting #000 to hsl(0deg 0% 0%), we convert it to hsl(none none 0%) with a missing hue and saturation.

There’s a lot more we can do with missing channels, even using ‘impossible’ colors in interesting ways, but I’ll leave that for a different article sometime.

Clamping vs scaling

So HWB is doing something familiar, but it’s also doing something unique. I don’t know of any other hue-angle system that generates a table like the HWB example above – where only half the cells contain a useful color.

Other color formats will clamp a channel when it goes outside the meaningful range. These HSL colors are the same, because percentages below 0% or above 100% are clamped to the boundaries of that range:

html {
  --red: hsl(0deg 100% 50%);
  --same-red-clamped: hsl(0deg 2530% 50%);
}

HWB works the same way – whiteness and blackness outside the 0%-100% range are clamped. But HWB has to go one step farther. If the sum of whiteness and blackness is above 100%, they both have to get scaled down while maintaining their relative ratio. That means these colors are also the same:

html {
  --gray: hwb(0deg 80% 20%);
  --same-gray-scaled: hwb(0deg 100% 25%);
  --same-gray-clamped-scaled: hwb(0deg 2530% 25%);
}

Wait, what’s the order of operations?

That final clamped-and-scaled color is what caught my attention. At first I wasn’t sure about the order of those two operations, and we get a different result depending on which happens first. Does 2530% 25% first get clamped to 100% 25% and then further scaled down to 80% 20% – or will it get scaled first to roughly 99% 1%, at which point it no longer needs clamping?

The answer is in the spec, though it’s a bit subtle and I missed it at first (emphasis added):

Values outside of these ranges are not invalid, but are clamped to the ranges defined here at computed-value time. If the sum of these two arguments is greater than 100%, then at computed-value time they are further normalized to add up to 100%, with the same relative ratio.

At first that seemed like a mistake – why not maintain the ratio where we can? – but in practice it makes sense. We have to clamp the lower boundary, since negative values have no meaning, so it’s reasonable to start by treating the upper boundary in the same way.

Out-of-range percentages are meaningless, and corrected like a typo, but redundant grays are a natural outcome of the math – still meaningful, even if they aren’t that useful.

The answer to my question

All of that, just to show you this light gray box. It’s a ‘gradient’ of the three HWB colors above, showing that they are actually the same color – one defined appropriately, one needing to be scaled, and a final version that needs both clamping and scaling:

Sass will soon be adding support for all these new color features, and a few more. If only I would stop documenting the rabbit holes, and get back to work on the proposal.