Skip to content

Perfect Gradients in CSS via Easing and the LAB Color Space

June 23, 2024

9 min read

703 views

Linear vs Eased Interpolation

The linear-gradient function in CSS interpolates colours linearly between provided stops along a line. This linear-ness can produce gradients that look uneven or harsh, especially between few stops, colours with different luminance or saturation levels, or when working with transparency. Assuming a constant rate of change between colours doesn't always align with how we perceive colour differences. The human eye is more sensitive to changes in certain colour ranges, which can make a purely linear transition look unnatural. This also applies to the colour values chosen in-between stops when mixing in the RGB colour space, as I'll detail later.

Easing functions can be applied to colour transitions to create more natural and visually-pleasing gradients. These functions adjust the rate of change, making the transition smoother and more gradual. By easing between multiple colour stops within linear-gradient, the gradient progression can be made to more closely mimic natural colour blending, resulting in a more harmonious look.

Here's a visual example – the same two colours over a white background, interpolated linearly and with an easing function applied:

The first bar uses linear interpolation, and doesn't appear visually-balanced. The colours bleed into each other nearly right away. Using an 'ease-in-out' function, the second appears much more even and visually-pleasing as a transition between two colours.

The unpleasing dirty-looking mix in the middle is another issue, to do with how colours are blended in the RGB colour space in CSS. However, it's visible how the start and end are much more vibrant, clearly closer to the original colours, and mixing begins closer to the centre.

Easing Gradients

Implementing eased linear gradients in vanilla CSS involves manually calculating the eased positions for each color stop. The above two examples are written as:

background: linear-gradient(90deg, #595650, #ff4800);

background: linear-gradient(90deg, #595650 0%, #595650 5.2631578947%, #5b564f 10.5263157895%, #5d564e 15.7894736842%, #60554c 21.0526315789%, #64554a 26.3157894737%, #6a5548 31.5789473684%, #705445 36.8421052632%, #765442 42.1052631579%, #7e533e 47.3684210526%, #87523a 52.6315789474%, #915135 57.8947368421%, #9b5030 63.1578947368%, #a74f2b 68.4210526316%, #b34e25 73.6842105263%, #c04d1e 78.9473684211%, #cf4c17 84.2105263158%, #de4b10 89.4736842105%, #ee4908 94.7368421053%, #ff4800 100%);

While certainly possible to do, it's a tedious and error-prone process - not really feasable for large projects and painful to update in the future.

Thankfully, it's fairly straightforward to leverage SCSS functions for automating the creation of such eased gradients.

SCSS Functions

First, we define an easing function to determine the rate of change for each colour stop. A basic 'ease' function looks like:

@function ease($t) { @return $t * $t; }

An 'ease-in-out' function is also fairly simple and is what I settled on using as it provides the best-looking results:

@function ease-in-out($t) { @if ($t < 0.5) { @return 2 * $t * $t; } @else { @return -1 + (4 - 2 * $t) * $t; } }

Next, a function that generates the linear-gradient rule using the easing function:

@function ease-gradient($dir, $color1, $color2) { $stops: 20; // Number of stops for a smoother gradient $percentage-step: 100% / ($stops - 1); $gradient: ''; @for $i from 0 through ($stops - 1) { $percentage: $i * $percentage-step; $eased-percentage: ease($percentage / 100); $interpolated-color: mix($color2, $color1, $eased-percentage * 100%); @if $i == 0 { $gradient: '#{$interpolated-color} #{$percentage}'; } @else { $gradient: '#{$gradient}, #{$interpolated-color} #{$percentage}'; } } $gradient-string: unquote('linear-gradient(#{$dir}, #{$gradient})'); @return $gradient-string; }

This function wraps our easing function, positioning each stop and mixing the colours appropriately. For simplicity, it only handles a start-colour and end-colour, although it wouldn't be too hard to take in a list of colours / positions, just as CSS's linear-gradient does.

Fixing RGB Colour Mixing

As mentioned prior, when creating gradients between two complimentary colours, such as purple and green, you might encounter dirty or muddy colors in the middle due to the way colors are interpolated in the RGB colour space. This is because the RGB colour space does not account for human perception of colour differences. To achieve smoother and more visually appealing gradients, we can use the LAB colour space, which is designed to be more perceptually uniform.

What is the LAB Colour Space?

The LAB colour space (CIELAB) is a color space that describes colours more simillarly to how humans perceive them. It consists of three components:

  • L: Lightness (0 to 100)
  • A: Green to red (-128 to 127)
  • B: Blue to yellow (-128 to 127)

Unlike the RGB colour space, which is device-dependent and based on mixing red, green, and blue light, the LAB colour space is designed to be perceptually uniform. This means that a given numerical change in LAB values corresponds to a similar perceived change in colour. This makes it ideal for tasks like colour interpolation, where the goal is to create smooth and natural transitions.

Here's some visual examples of mixing between values in RGB vs in LAB. Which do you think look more pleasing? 🙃

Implementing LAB Color Interpolation in SCSS

To interpolate colours in the LAB color space, we first need to convert RGB colours to LAB, perform the interpolation, and then convert the LAB colours back to RGB for insertion into the linear-gradient string.

Below are functions for these conversions - RGB to LAB and vice versa:

@function rgb-to-lab($color) { $r: red($color) / 255; $g: green($color) / 255; $b: blue($color) / 255; // RGB to XYZ @if $r > 0.04045 { $r: pow((($r + 0.055) / 1.055), 2.4); } @else { $r: $r / 12.92; } @if $g > 0.04045 { $g: pow((($g + 0.055) / 1.055), 2.4); } @else { $g: $g / 12.92; } @if $b > 0.04045 { $b: pow((($b + 0.055) / 1.055), 2.4); } @else { $b: $b / 12.92; } $x: ($r * 0.4124564 + $g * 0.3575761 + $b * 0.1804375) / 0.95047; $y: ($r * 0.2126729 + $g * 0.7151522 + $b * 0.072175) / 1; $z: ($r * 0.0193339 + $g * 0.119192 + $b * 0.9503041) / 1.08883; // XYZ to LAB @if $x > 0.008856 { $x: pow($x, 1/3); } @else { $x: (7.787 * $x) + (16 / 116); } @if $y > 0.008856 { $y: pow($y, 1/3); } @else { $y: (7.787 * $y) + (16 / 116); } @if $z > 0.008856 { $z: pow($z, 1/3); } @else { $z: (7.787 * $z) + (16 / 116); } $l: (116 * $y) - 16; $a: 500 * ($x - $y); $b: 200 * ($y - $z); @return ($l, $a, $b); } @function lab-to-rgb($lab) { $l: nth($lab, 1); $a: nth($lab, 2); $b: nth($lab, 3); $y: ($l + 16) / 116; $x: $a / 500 + $y; $z: $y - $b / 200; @if pow($y / 1%, 3) > 0.008856 { $y: pow($y / 1%, 3); } @else { $y: ($y / 1% - 16 / 116) / 7.787; } @if pow($x / 1%, 3) > 0.008856 { $x: pow($x / 1%, 3); } @else { $x: ($x / 1% - 16 / 116) / 7.787; } @if pow($z / 1%, 3) > 0.008856 { $z: pow($z / 1%, 3); } @else { $z: ($z / 1% - 16 / 116) / 7.787; } $x: $x * 0.95047; $y: $y * 1; $z: $z * 1.08883; $r: $x * 3.2404542 + $y * -1.5371385 + $z * -0.4985314; $g: $x * -0.969266 + $y * 1.8760108 + $z * 0.041556; $b: $x * 0.0556434 + $y * -0.2040259 + $z * 1.0572252; @if $r > 0.0031308 { $r: 1.055 * pow($r, (1 / 2.4)) - 0.055; } @else { $r: 12.92 * $r; } @if $g > 0.0031308 { $g: 1.055 * pow($g, (1 / 2.4)) - 0.055; } @else { $g: 12.92 * $g; } @if $b > 0.0031308 { $b: 1.055 * pow($b, (1 / 2.4)) - 0.055; } @else { $b: 12.92 * $b; } @return rgb($r * 255, $g * 255, $b * 255); }

Second, a helper for calculating the blending at any given position:

@function interpolate-lab($start-color, $end-color, $position) { $start-lab: rgb-to-lab($start-color); $end-lab: rgb-to-lab($end-color); $position: $position / 1%; $l: nth($start-lab, 1) + (nth($end-lab, 1) - nth($start-lab, 1)) * $position; $a: nth($start-lab, 2) + (nth($end-lab, 2) - nth($start-lab, 2)) * $position; $b: nth($start-lab, 3) + (nth($end-lab, 3) - nth($start-lab, 3)) * $position; @return lab-to-rgb(($l, $a, $b)); }

Now, we can replace SCSS's mix with our new interpolate-lab function:

@function ease-gradient($dir, $color1, $color2) { // ... $interpolated-color: interpolate-lab($color1, $color2, $eased-percentage); // ... }

It's important to note that this exact colour mixing implementation won't work with transparency. With some more tweaking, it could, but I didn't go that far since I just wanted interpolation between solid colours. One way might be to calculate the alpha channel's positioning seperately, then recombine it with rgba after rgb_to_lab and vice versa.

Fin

And here's the end result, mixed in the LAB space and eased in/out, versus the stock mix and interpolation you'd get from linear-gradient.

If you want to have some more fun, try using other easing functions rather than the simple 'ease' or 'ease-in-out' demo'd here – it's fairly simple to achieve some cool effects!

That's all, though – I hope this info is useful to you in some form! Even ignoring the more-complex LAB space colour mixing, I've found even just using some easing can make a big difference in how gradients look, especially when used as overlays, backgrounds, or in places transparency is needed. Please feel free to reach out if you have any questions about the stuff covered in this post. Happy CSS-ing 🖌️