Smoother Gradients with Spline Interpolation
How spline interpolation and perceptual color spaces can create more natural-looking gradients than standard RGB interpolation.

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 gradient with
red
, green
, and blue
color stops
Looks not really smooth. This approach has two main issues:
- The RGB color space is neither linear nor perceptually uniform, leading to unexpected «muddy» or «washed» colors, appearing during interpolation.
- 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
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.
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
Perceptually uniform color spaces like
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
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.
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
Generate
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
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
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
- Initial idea was taken from this Notebook «Perceptually Smooth Multi-Color Linear Gradients» by Matt DesLauriers. ↩
- 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. ↩ - 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. ↩