Smoother Gradients with Spline Interpolation

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

|Color

While exploring the idea of using gradients for project, 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 sRGB, chosen for backwards compatibility and web performance. 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 gradient with red, green, and blue color stops

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

  1. The sRGB 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,
solid line — linear, dashed line — spline, square — data points

In this fragment, linear interpolation connects data points with straight segments that form sharp connections, 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.

Hue strip comparison in HSL vs OkLCh
0
30
60
90
120
150
180
210
240
270
300
330
360
Hue: none Range: 0° - 360°

Perceptually uniform color spaces like L∗a∗b∗L^*a^*b^*, OklabOklab, or OkLChOkLCh 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 LinearRGBLinear RGB, L∗a∗b∗L^*a^*b^*, OklabOklab, JzazbzJ_z a_z b_z, or XYBXYB. Among these, OklabOklab 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.

6 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 OkLChOkLCh color space defines colors using Lightness (LL), Chroma (CC), and Hue (hh), which helps create smooth, perceptually uniform color transitions. This algorithm generates a sequence of nn colors with constrained transitions in the OkLChOkLCh space.

Generate nn colors, each defined as ci=(Li,Ci,hi)\mathbf{c}_i = (L_i, C_i, h_i), where:

Li∈[0,1]L_i \in [0, 1]
Ci∈[Cmin,Cmax]C_i \in [C_{\text{min}}, C_{\text{max}}]
hi∈[0,360)h_i \in [0, 360)

For smooth transitions, limit differences between consecutive colors:

∣Li−Li−1∣≤ΔL|L_i - L_{i-1}| \leq \Delta_L
∣Ci−Ci−1∣≤ΔC|C_i - C_{i-1}| \leq \Delta_C
∣hi−hi−1∣≤Δh|h_i - h_{i-1}| \leq \Delta_h

Process steps:

  • First color (i=1i=1): Sample L1L_1, C1C_1, h1h_1 uniformly from their ranges.
  • Subsequent colors (i≥2i \geq 2): Sample each component relative to the previous color, using random perturbations within ΔL\Delta_L, ΔC\Delta_C, Δh\Delta_h, and clamp to ensure values stay within bounds.
  • Converting to CSS: each color (Li,Ci,hi)(L_i, C_i, h_i) can be converted into a CSS-compatible OkLChOkLCh 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.

Algorithm result
This fragment uses mulberry32 as its PRNG. In contrast, the Python code above relies on NumPy's MT19937, which has higher overhead and is unpractical for lightweight web environment.

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 OkLab 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 RGB or HSL interpolation. ↩
  3. The CSS OkLCh 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. ↩

  1. sRGB: Color profile that covers 35% of the human visible spectrum, is the global web standard, supported by virtually all consumer monitors and browsers.
  2. Linear interpolation: Method to estimate a value between two known points by connecting them with a straight line and calculating the value based on a proportional distance along that line.
  3. spline interpolation: Method that estimates values between points using a piecewise polynomial curve (spline), ensuring smoothness and continuity at the points where the polynomial segments join.