HDR color on the Web
Typically, UI design for the web uses colors in the "standard rgb" or sRGB
color space. This is an aging standard; sRGB is a small subset of the colors that the human eye can perceive, and most modern displays can reproduce a much wider range of colors. For example, Apple devices have standardized around the "P3" colorspace for implementing HDR color.
Most browsers have supported various color spaces for image and video assets for some time, but not necessarily in markup. Safari began supporting P3 color in 2016, and Chrome finally began fully supporting it in 2023.
P3 is a superset of sRGB. Any color that is described as sRGB can also be described as P3. For example, #ff0000
in sRGB is #ea3323
in the P3 colorspace. The same hex value (#ff0000
) equates to a much brighter green when it's in P3.
Inversely, many colors exist in the Display P3 space that are not in the sRGB space. Display P3 is wider gamut and it can represent more, especially deep reds and greens. #ff0000
in Display P3 can not be described as an sRGB hex value, because it is completely out of the sRGB range.
If you're designing in standard RGB, you're leaving a lot of color on the table.
Since Chrome (and consequently, Figma) were relatively late on delivery for broader colorspace support, designers are still working predominantly with sRGB on web surfaces.
Designing and developing UI in the P3 colorspace allows you to use colors that are more saturated and vibrant, and generally, a much larger palette to work with, which is extremely useful for UI systems that rely on color intensity or opacity for heirarchy indication.
Additionally, if you have image and video assets in Display P3, you'll have be able to match the colorspaces of those assets with your UI.
Stylesheet implementation
To utilize color gamuts other than sRGB, you typically need to use the color()
function. The color()
function allows you to specify the color space as well as the color value. This uses syntax more similar to rgb()
, but where each channel is a decimal value from 0 to 1, rather than from 0 to 255.
You typically want to support rendering conditions where colors outside of the sRGB space are not available. (Linux systems are a common case). Naively sampling P3 hex values down to sRGB will often result in a different color than intended.
We can check for P3 color support and use display-P3
syntax with sRGB as a fallback:
:root {
--super-green: rgb(0, 255, 0);
}
/* Display-P3 color, when supported. */
@supports (color: color(display-p3 1 1 1)) {
:root {
--super-green: color(display-p3 0.165 0.722 0.211 / 1);
}
}
header {
color: var(--super-green);
}
This is pretty inconvenient when sampling colors from design software, which typically display hex values regardless of the chosen color space.
If we don't want to have to manually convert our all our P3-space RGB values to this syntax, we can create a destructured list of RGB values and calculate them inline in sass
. Each value is divided by 255 to derive the decimal value for the display-P3
syntax:
$rgb-channels: "darker-gray" "21" "22" "22", "super-green" "42" "184" "54";
:root {
@each $name, $r, $g, $b in $rgb-channels {
--#{$name}: rgb(#{$r}, #{$g}, #{$b});
}
}
@supports (color: color(display-p3 1 1 1)) {
:root {
@each $name, $r, $g, $b in $rgb-channels {
--#{$name}: color(
display-p3 calc(#{$r}/ 255) calc(#{$g}/ 255) calc(#{$b}/ 255)
);
}
}
}
This empowers us to use the same RBG syntax for both wide-gamut and non-wide-gamut devices. However, we still need to optimize our P3-origin RGB values for sRGB, where colors exist outside of the sRGB gamut, or they will be clipped:
$rgb-channels: //P3 values sRGB values
"darker-gray" "21" "22" "22" "21" "22" "22", // no change
"super-green" "42" "184" "54" "0" "187" "8"; // optimized for each
:root {
@each $name, $r, $g, $b, $sr, $sg, $sb in $rgb-channels {
--#{$name}: rgb(#{$sr}, #{$sg}, #{$sb});
}
}
@supports (color: color(display-p3 1 1 1)) {
:root {
@each $name, $r, $g, $b, $sr, $sg, $sb in $rgb-channels {
--#{$name}: color(
display-p3 calc(#{$r}/ 255) calc(#{$g}/ 255) calc(#{$b}/ 255)
);
}
}
}
...which compiles to:
:root {
--darker-gray: rgb(21, 22, 22);
--super-green: rgb(0, 187, 8);
}
@supports (color: color(display-p3 1 1 1)) {
:root {
--darker-gray: color(
display-p3 calc(21 / 255) calc(22 / 255) calc(22 / 255)
);
--super-green: color(
display-p3 calc(42 / 255) calc(184 / 255) calc(54 / 255)
);
}
}
This methodology enables full color range on wide-gamut/P3 devices, and repairs skewed colors on sRGB devices — but is an arduous process when manually converting design assets back and forth to retrieve RGB values for dozens of primitives.
Using hex values universally for sRGB and P3
We need a more scalable way of managing color for web design — Ideally in the broader color space, with automated fallbacks for sRGB.
There's a pure stylesheet solution for handling this:
@function rgb($hexcolor) {
$red: red($hexcolor);
$green: green($hexcolor);
$blue: blue($hexcolor);
$alpha: alpha($hexcolor);
@return unquote("rgb(#{$red},#{$green},#{$blue})");
}
:root {
--color: #{rgb(#00ff00)};
}
@supports (color: color(display-p3 1 1 1)) {
@function display-p3($hexcolor) {
$red: red($hexcolor);
$green: green($hexcolor);
$blue: blue($hexcolor);
$alpha: alpha($hexcolor);
@return unquote("color(display-p3 #{$red} #{$green} #{$blue} / #{$alpha})");
}
:root {
--color: #{display-p3(#00ff00)};
}
}
div {
background-color: var(--color);
}
div.plain-hex {
background-color: #00ff00;
}
Even though the color value was stored as a hex, it's converted by the SCSS function and rendered in display-p3 wide gamut.
This is especially useful when working in wide gamut color projects in design software like Figma and Sketch, which represent color values as hex values regardless of color profile.