All Posts
Color|

Smoother Gradients with Spline Interpolation

How spline interpolation and perceptual color spaces can create more natural-looking gradients than standard RGB interpolation.

How spline interpolation and perceptual color spaces can create more natural-looking gradients than standard RGB interpolation.
7 min read | 1.3K words

While exploring the idea of using gradients for ████████, I began looking into what makes gradients feel more natural.

Gradients are everywhere in modern design, yet most of them share a common flaw: they don't look natural. Those muddy brown patches between complementary colors, the harsh banding in smooth progressions.

The problem is how we typically create gradients. Standard CSS uses linear interpolation in RGB, chosen for speed rather than quality. This creates a mismatch between how computers calculate color changes and how humans actually perceive them.

By combining spline interpolation with perceptually uniform color, we can create gradients that look genuinely smooth. Here's how we can «fix» that.

Problem

When you attempt to create a linear gradient by evenly spacing colors, the result often shows unexpected color transitions. Here's an example of such a linear gradient:

Linear gradientLinear gradient with red, green, and blue color stops

Looks not really smooth. This approach has two main issues:

  1. The RGB color space is neither linear nor perceptually uniform, leading to unexpected «muddy» or «washed» colors, appearing during interpolation.
  2. Linear interpolation creates sharp transitions at each color stop, resulting in a less smooth gradient.

Here how we can solve that issues.

Spline Interpolation

To create a truly smooth gradient, linear interpolation isn't enough. It's fast and easy to implement, but it doesn't always produce the smoothest transitions. This is where spline interpolation, combined with a perceptual color space, can help.1

Interpolation in 2D space
Linear
Spline
Data Point

In this example, linear interpolation connects data points with straight segments that form sharp junctions, which aren't ideal for smooth gradients. In contrast, the Catmull-Rom Spline generates a smoother, more natural-looking curve that provides smooth transitions between points.

Catmull-Rom spline can overshoot or undershoot color space bounds, so we need to keep interpolated color components within valid channel limits.

Perceptual Color Spaces

The human visual system doesn't perceive color changes uniformly across all hues, brightness levels, or saturation values.

For example, our perception of brightness follows a non-linear curve as we're more sensitive to changes in darker tones than brighter ones, even when the mathematical differences are identical.

This non-uniformity in human color perception is why the RGB color space, despite being computationally convenient, often produces unexpected results when interpolating between colors.

Equal steps in RGB values don't correspond to equal steps in visual perception, leading to those «muddy» or «washed out» intermediate colors we see in traditional gradients.

Perceptual uniformityHue strip comparison in HSL vs OkLCh

Perceptually uniform color spaces like , , or solve this problem by organizing colors according to how humans actually see them.

In these spaces, equal numerical distances correspond to roughly equal perceived color differences. When we interpolate between colors using these spaces, each step in our gradient represents a consistent visual change, resulting in smoother, more natural-looking transitions.2

For gradient creation, we can choose from several perceptually uniform color spaces such as , , , , or . Among these, is most effective for generating predictable gradients.

For a more detailed explanation of color spaces and perceptual uniformity, I recommend the «What is a color space?» chapter from «Making Software» by Dan Hollick. It's an excellent read on color theory, full of clear explanations.

Implementation

Now it's time to write the code. Here is the Python implementation that creates 3 colors in OkLCh space within ranges of lightness and chroma components. It converts existing OkLCh colors to the OkLab color space to use with Catmull-Rom Spline interpolation.

python
from coloraide import Color
from coloraide.interpolate.catmull_rom import CatmullRom
from PIL import Image

class Custom(Color): ...
Custom.register(CatmullRom())

colors = [
    Custom.random('oklch', limits=[(0.5, 0.75), (0.05, 0.2)]).to_string()
    for _ in range(3)
]

interpolator = Custom.interpolate(colors, space='oklab', method='catrom')

width, height = 400, 400
image = Image.new('RGB', (width, height))
pixels = image.load()

for y in range(height):
    progress = y / (height - 1)
    color = interpolator(progress).convert('srgb').coords()
    r, g, b = [max(0, min(255, int(c * 255))) for c in color[:3]]
    for x in range(width):
        pixels[x, y] = (r, g, b)  # type: ignore

image.save('gradient.png', format='PNG')
print("Image saved as gradient.png")

The results were good overall, but they still felt somewhat synthetic. The random color generation sometimes produced strange, unnatural color pairings that, while mathematically valid, didn't feel cohesive.

This is why we need a more controlled approach to generating color stops that maintains natural relationships between consecutive colors.

Gradients6 gradient samples

Smoother Color Samples

Now that we can work with color samples, we need something simple yet complex enough to create smooth samples suitable for smooth transitions.

The color space defines colors using Lightness (), Chroma (), and Hue (), which helps create smooth, perceptually uniform color transitions. This algorithm generates a sequence of colors with constrained transitions in the space.

Generate colors, each defined as , where:

For smooth transitions, limit differences between consecutive colors:

Process steps:

  • First color (): Sample , , uniformly from their ranges.
  • Subsequent colors (): Sample each component relative to the previous color, using random perturbations within , , , and clamp to ensure values stay within bounds.
  • Converting to CSS: each color can be converted into a CSS-compatible string (e.g., oklch(0.75 0.15 240)) for use in code.3
python
import numpy as np

def random_uniform(min_val: float, max_val: float, seed: int | None = None) -> float:
    if seed is not None:
        np.random.seed(seed)
    return np.random.uniform(min_val, max_val)

def clamp(value: float, min_val: float, max_val: float) -> float:
    return max(min_val, min(value, max_val))

def generate_colors(
    n_colors: int,
    l_range: tuple[float, float] = (0.75, 0.85),
    c_range: tuple[float, float] = (0.05, 0.15),
    h_range: tuple[float, float] = (0, 360),
    l_delta: float = 0.05,
    c_delta: float = 0.03,
    h_delta: float = 60,
    seed: int | None = None
) -> list[str]:
    if n_colors < 1:
        raise ValueError("n_colors must be positive")

    colors = []

    l = random_uniform(l_range[0], l_range[1], seed)
    c = random_uniform(c_range[0], c_range[1])
    h = random_uniform(h_range[0], h_range[1])
    colors.append(f"oklch({l*100:.1f}% {c:.3f} {h:.1f})")
    prev_l, prev_c, prev_h = l, c, h

    for _ in range(1, n_colors):
        l = clamp(prev_l + random_uniform(-l_delta, l_delta), l_range[0], l_range[1])
        c = clamp(prev_c + random_uniform(-c_delta, c_delta), c_range[0], c_range[1])
        h = clamp((prev_h + random_uniform(-h_delta, h_delta)) % 360, h_range[0], h_range[1])
        colors.append(f"oklch({l*100:.1f}% {c:.3f} {h:.1f})")
        prev_l, prev_c, prev_h = l, c, h

    return colors

def main():
    colors = generate_colors(4)
    for i, color in enumerate(colors, 1):
        print(f"{i:2}: {color}")

if __name__ == "__main__":
    main()

Results

The combination of controlled color sampling, Catmull-Rom spline interpolation, and the OkLab color space produces gradients that feel genuinely natural.

By constraining the differences between consecutive color stops, we avoid jarring combinations while maintaining visual interest throughout the transition.

GradientsAlgorithm result

Where linear RGB interpolation produces muddy intermediates and harsh transitions, this approach preserves vibrancy and creates smooth, organic progressions. The controlled generation ensures harmonious color relationships, while spline interpolation eliminates the «mechanical» feel of linear gradients.

Conclusion

By combining spline interpolation with perceptually uniform color spaces, we can create gradients that more closely match human visual expectations. While this approach requires more computational resources than simple linear interpolation in RGB, the visual improvements are substantial.

The OkLab color space provides the foundation for perceptually consistent color transitions, while Catmull-Rom splines eliminate the mechanical feel of linear interpolation, producing gradients that feel more natural and less synthetic.

For designers and developers looking to create more sophisticated color transitions, this technique offers a significant upgrade over standard CSS gradients.

Footnotes

  1. Initial idea was taken from this Notebook «Perceptually Smooth Multi-Color Linear Gradients» by Matt DesLauriers.
  2. The color space is designed to be perceptually uniform, which means equal distances in the space correspond to equal perceptual differences in color, reducing artifacts common in or interpolation.
  3. The CSS color function, e.g. oklch(75% 0.15 140) is a modern color syntax that represents colors in the OkLCh color space, allowing for perceptually uniform color manipulation in web design.