Understanding CSS Color Gradients for the Front-end and Data Visualization

T.L.;D.R.: What’s a color gradient anyway? “[A] Color gradient is a set of colors arranged in a linear order” (Wikipedia contributors 2023a). And in the context of a web browser, there’s CSS syntax to declare gradients (MDN contributors 2023a). But to start to master it, we should take a look under the hood. And that’s what this article aims at.

Alexandre Lopes
10 min readMar 7, 2023

The problem with linearly interpolating sRGB colors

CSS can declare multiple gradient shapes, such as the linear-gradient() — a color function used to paint an area with colors in a chosen direction along a straight line (MDN contributors 2023e). Using it to draw a gradient from one color — on the left — to another — on the right — is a straightforward way to visualize what happens between those two color stops. The example below declares those stops using the rgb() color function (MDN contributors 2023i).

.my-gradient {
background-image: linear-gradient(
to right,
rgb(255, 255, 0),
rgb(0, 0, 255)
Yellow to Blue in sRGB linear-gradient.

Just as the CSS Images Module Level 4 specification acknowledged, there’s grey in the middle (Atkins Jr., J. Etemad, and Verou 2023, chap. 3.1.1 example 19). So, why is that? You see, CSS gradients depend on interpolating colors. That happens by first converting each color to an interpolation color space, such as sRGB if none is specified, and then interpolating each component individually (Atkins Jr., J. Etemad, and Verou 2023). If you got yellow rgb(255, 255, 0) on one side and blue rgb(0, 0, 255) on the other, at the center sits its arithmetic average: grey rgb(128, 128, 128). It’s mathematically correct and inside the spec but not perceptually accurate, as it looks like there’s a third stop in a two-stop gradient. That could be fixed by adding more stops in between, thus removing that grayish area until it looks just as uniform as if you had drawn it in a more perceptually uniform color space. That laborious task should result in something that looks like the example below.

Yellow to Blue in Oklab linear-gradient.

There are better solutions than manually adding stops to smooth out the gradient, and I’ll go through them. But first, let’s check why that is not so good of an idea in the first place.

Shopping for a color

Choosing a color and adjusting it is a mundane task. One a developer may do several times a day. It may even pick colors programmatically. But often, this task starts with picking colors manually using the color picker. Think of this tool — the color picker — as a department store, and feel this task — picking colors — as going shopping at that store.

To find the product you wish, you must browse through rows of fixtures until you find the row you want. Then you enter that row and walk along it until you find the right fixture. You look up and down that fixture, find the desired product, and grab it. Or, in our analogy, pick the color you want.

Color picker RGB Sliders.

A color picker has colors ordered in multiple dimensions, like the products in that store. And what those dimensions represent depends on the color model. We come from a linear-gradient() using rgb(), so we already use the RGB model. That is a three-dimensional cartesian space in which each axis represents the intensity of one colored light: Red, Green, and Blue. When every color component is at its max, you got white. At absolute 0, there’s black.

RGB Cube in Three.js.

But, as you may have experienced, there are more intuitive ways to browse for color than inputting red, green, and blue amounts into fields. Let’s say you have a button and want to make its border slightly darker. Finding the proper brightness usually means fiddling with the amount of all those primary colors simultaneously while being very careful not to change the color itself — its hue.

That’s one of the reasons why you may find another type of color model in CSS¹ and the color picker itself. The HSL color model: where H stands for Hue, expressed as an angle; S for Saturation, expressing how grey or intense a color is; and L for Lightness, how dark or bright a color is.

Color picker HSL Sliders.
HSL Bicone in Three.js.

That’s better. Making minor adjustments now is more manageable as you can make colors grayer, lighter, or darker without changing the hue component. But, in using HSL, you may encounter another annoyance that may manifest in the following manner. Let’s say you are building a color palette that uses yellow and blue, just like in that gradient we started with. So you put in the hue, max out the saturation and input the same lightness value. You end up with an eye-hurting yellow and a calm blue. To make both the same intensity, you decrease the yellow’s lightness, resulting in something like this:

That’s because rgb(), hsl(), named colors, and HEX codes are different color models — they present different ways to navigate colors. But, they all navigate through the same sRGB color space and are all bound to its characteristics (MDN contributors 2023h).

Characteristics such as having Red, Green, and Blue primaries and being gamma encoded — an encoding inherited from CRT monitors that helps color to be more perceptually accurate (Wikipedia contributors 2023d); but also makes arithmetics harder because the same expression applied at different light intensities will yield irregular results (Moulin 2018).

Gamma color space. (Moulin 2018)

Then there’s the fact that sRGB represents all light wavelengths — perceived by the eye as hue — with the same intensity. And that is great for physics and colorimetry. But human eyes don’t perceive brightness uniformly across hues, which is not easy to account for. Björn Ottosson (2020) created the visualization below. He compares a perceptually more uniform color space he designed (Oklab) with sRGB by changing hues and maintaining the same saturation and lightness values. Then he demonstrates how sRGB hues do not translate to uniform lightness by converting the sRGB image to black and white in the Oklab color space.

sRGB, Oklab, and sRGB converted to gray in Oklab.

Remember that metaphor we used about picking a color being like browsing a department store? Well, a gradient is like picking two or more colors and walking between them: if the store layout — how colors are organized in space — changes, then the path between two products — colors — also changes.

Thankfully CSS Images Module Level 4 accepts declaring and interpolating in color spaces other than sRGB (Atkins Jr., J. Etemad, and Verou 2023), even advocating for Oklab. But before going on further about color spaces in CSS, let’s talk about color spaces.

The year is 1931…

Ernest Lawrence invents the cyclotron. Porsche is founded. Mahatma Gandhi is released from imprisonment. And the International Commission on Illumination creates the CIE 1931 XYZ color space (Wikipedia contributors 2023b, 2023c). As Abraham (2019) put it, CIE 1931 does not aim to tell how humans perceive color. It aims at creating a system in which color can be numerically measured, expressed, and reproduced in print media or light-emitting displays, just like those displays showing colors carefully programmed with CSS.

As a color-matching system, CIE XYZ represents the light wavelengths a human can see² and is based on physics, making it device-independent. That’s why it’s used as a standard to profile the colors a device can reproduce. But it also works the other way around: establishing parameters for industry standards that produced devices must attend to and be measured against, such as sRGB.

The sRGB gamut represented within the XYZ D65 color space.

But measuring wavelengths for accurate color-matching and reproduction is one thing; being perceptually uniform is another altogether. And for that goal, CIE proposed the L*a*b* color space, where L stands for perceived lightness; a: how green/red a color is; and b: how blue/yellow a color is. Just that change in components itself guarantees a color space that’s perceptually more uniform than CIE XYZ (MDN contributors 2023c).

L*a*b* Sphere in Three.js.

Even though L*a*b* is perceptually more accurate than CIE XYZ, Björn Ottosson’s Oklab is even more, as it further incorporates mechanisms to improve arithmetics and perceptual uniformity. As Ottosson himself wrote, it is “[…] a new perceptual color space, designed to be simple to use, while doing a good job at predicting perceived lightness, chroma, and hue” (Ottosson 2020).

Now, let’s plot all sRGB colors inside the Oklab color space, visualizing their organization concerning human perception. And the resulting solid is far from regular, distancing itself from the rgb() cube, the hsl() bicone, or its cylindrical cousin, HSV — more commonly used in photo editing applications.

The sRGB gamut represented within the Oklab color space.
Uniform color space.

Back to the problem of interpolating colors in different color spaces

With a deeper understating of color spaces and color models, we are equipped to explain why some other phenomena happen when interpolating sRGB colors. One of these phenomena is the hue shift to purple in white-to-blue gradients such as the one below.

White to blue in sRGB linear-gradient.

That’s another side effect of mixing sRGB primaries in a linear but not perceptually uniform way. The path from white to blue is a straight line, but it visibly crosses purple hues. Unfortunately, this specific problem also affects lab() and its cylindrical representation, lch() MDN contributors (2023d). Thankfully there are oklab() and oklch() CSS functions MDN contributors (2023g), just as explored by Sitnik and Turner (2022) on “OKLCH in CSS: why we moved from RGB and HSL”.

“A constant-hue slice of LCH and OKLCH spaces with the same hue. The LCH slice is blue on one side and purple on the other. OKLCH keeps a constant hue as expected.” (Sitnik and Turner 2022).

And just as expected, if you draw the same gradient interpolating colors in Oklab, that hue shift is gone³.

White to blue in Oklab linear-gradient.

But there’s a catch, and if you’re not using Safari, you may already have spotted it. As of March 2023, only Safari has implemented CSS Color Module Level 4 color function and other features the demos on this page rely on (can i use contributors 2023). But, thanks to Color.js, a library written by W3C contributors Lea Verou and Chris Lilley (2023), I was able to make brute-force approximations that automatically runs if they fail to support required features. This approach was primarily inspired by Comeau’s (2022) article Make Beautiful Gradients.

Thank you for reading this article. If you think I missed something or want to discuss this topic, please reach out at alexandrelopes.design, where you can find my updated contact information.

One more thing

Browsers will always take the shortest route when interpolating hue in color models like HSL, where hue is expressed as an arc. But CSS Color Module Level 4 allows the choice of other interpolation paths, such as the longer, increasing, or decreasing (Atkins Jr., Lilley, and Verou 2023, chap. 12). That will allow web developers to bring in new types of gradients, such as the one below. Adam Argyle (2022) explores this topic with even more examples if you are curious about it.

Red to red in Oklab linear-gradient along longer hue.


Abraham, Chandler. 2019. “A Beginner’s Guide to (CIE) Colorimetry.” Color and Imaging. https://medium.com/hipster-color-science/a-beginners-guide-to-colorimetry-401f1830b65a.

Argyle, Adam. 20232. “Gradient Hue Interpolation.” https://nerdy.dev/gradients-going-the-shorter-longer-increasing-or-decreasing-route.

Atkins Jr., Tab, Elika J. Etemad, and Lea Verou. 2023. “CSS Images Module Level 4.” W3C. https://www.w3.org/TR/css-images-4/.

Atkins Jr., Tab, Chris Lilley, and Lea Verou. 2023. “CSS Color Module Level 4.” W3C. https://www.w3.org/TR/css-color-4/.

Comeau, Josh W. 2022. “Make Beautiful Gradients.” https://www.joshwcomeau.com/css/make-beautiful-gradients/.

contributors, Can I Use. 2023. “Feature: CSS Color() Function.” https://caniuse.com/css-color-function.

MDN contributors. 2023a. “<Gradient>.” MDN. https://developer.mozilla.org/en-US/docs/Web/CSS/gradient.

— — — . 2023b. “Hsl().” MDN. https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl.

— — — . 2023c. “Lab().” MDN. https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/lab.

— — — . 2023d. “Lch().” MDN. https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/lch.

— — — . 2023e. “Linear-Gradient().” MDN. https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/linear-gradient.

— — — . 2023f. “Oklab().” MDN. https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklab.

— — — . 2023g. “Oklch().” MDN. https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch.

— — — . 2023h. “RGB.” MDN. https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb.

— — — . 2023i. “Rgb().” MDN. https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb.

Moulin, Matthias. 2018. “Linear, Gamma and sRGB Color Spaces.” https://matt77hias.github.io/blog/2018/07/01/linear-gamma-and-sRGB-color-spaces.html.

Ottosson, Björn. 2020. “A Perceptual Color Space for Image Processing.”https://bottosson.github.io/posts/oklab/.

Sitnik, Andrey, and Travis Turner. 2022. “OKLCH in CSS: Why We Moved from RGB and HSL.” https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl#oklch-vs-oklab--lch-vs-lab.

Verou, Lea, and Chris Lilley. 2023. “Color.js.” https://colorjs.io.

Wikipedia contributors. 2023a. “Color Gradient.” Wikipedia. https://en.wikipedia.org/w/index.php?title=Color_gradient&oldid=1135775894.

— — — . 2023b. “1931.” Wikipedia. https://en.wikipedia.org/w/index.php?title=1931&oldid=1141750744.

— — — . 2023c. “CIE 1931 Color Space.” Wikipedia. https://en.wikipedia.org/w/index.php?title=CIE_1931_color_space&oldid=1141092007.

— — — . 2023d. “sRGB.” Wikipedia. https://en.wikipedia.org/wiki/SRGB.

¹ To declare color using the HSL color model in CSS, you use the hsl() color function (MDN contributors 2023b). ↩︎

² Or at least the humans involved in the experiments made during the 1920s that were used as a basis for CIE XYZ. ↩︎

³ observe that the gradient begins with colors that fit perfectly into sRGB color space: white hsl(0, 0, 100) and blue hsl(240, 100, 50). But as its path across Oklab color space goes beyond the sRGB surface, some of its values go beyond 100% when converted back to hsl(). ↩︎