Skip to content

Color Interpolation

Interpolation is a type of estimation that finds new data points based on the range of a discrete set of known data points. When used in the context of color, it is finding one or more colors that reside between any two given colors. This is often used to simulate mixing colors, creating gradients, or even create color palettes.

ColorAide provides a number of useful utilities based on interpolation.

Linear Interpolation

Linear interpolation is registered in Color by Default

One of the most common, and easiest ways to interpolate data between two points is to use linear interpolation. An easy way of thinking about this concept is to imagine drawing a straight line that connects two colors within a color space. We could then navigate along that line and return colors at different points to simulate mixing colors at various percentages or return the whole range and create a continuous, smooth gradient.

To further illustrate this point, the example below shows a slice of the Oklab color space at a lightness of 70%. On this 2D plane, we select two colors: oklab(0.7 0.15 0.1) and oklab(0.7 -0.03 -0.12). We then connect these two colors with a line. We can then select any point on the line to simulate the mixing of these colors. 0% would yield the first color, 100% would yield the second color, and 50% would yield a new color: oklab(0.7 0.06 -0.01).

Linear Interpolation

Interpolation performed at 50%

The interpolate method allows a user to create a linear interpolation function using two or more colors. By default, a returned interpolation function accepts numerical input in the domain of [0, 1] and will cause a new color between the specified colors to be returned.

By default, colors are interpolated in the perceptually uniform Oklab color space, though any supported color space can be used instead. This also applies to all methods that use interpolation, such as discrete, steps, mix, etc.

As an example, below we create an interpolation between rebeccapurple and lch(85% 100 85). We then step through values of 0.0, 0.1, 0.2, etc. This returns colors at various positions on the line that connects the two colors, 0 returning rebeccapurple and 1 returning lch(85% 100 85).

>>> i = Color.interpolate(["rebeccapurple", "lch(85% 100 85)"], space='lch')
>>> [i(x / 10).to_string() for x in range(10 + 1)]
['lch(32.393 61.244 308.86)', 'lch(37.653 65.119 322.47)', 'lch(42.914 68.995 336.09)', 'lch(48.175 72.87 349.7)', 'lch(53.436 76.746 3.3143)', 'lch(58.696 80.622 16.929)', 'lch(63.957 84.497 30.543)', 'lch(69.218 88.373 44.157)', 'lch(74.479 92.249 57.771)', 'lch(79.739 96.124 71.386)', 'lch(85 100 85)']

If we create enough steps, we can create a gradient.

>>> i = Color.interpolate(
...     ["rebeccapurple", "lch(85% 100 85)"],
...     space='lch'
... )

CSS Linear Interpolation

New 2.11

CSS linear interpolation is registered in Color by Default

While ColorAide supports CSS color syntax, it's goal is not to necessarily mirror CSS in all aspects, though often we do provide ways ways to emulate the behavior.

The default linear interpolation that ColorAide uses by default deviates from how CSS handles interpolation. More specifically, it deviates in how undefined hues are resolved during the interpolation steps which directly affects achromatic interpolation results. The difference in handling is subtle, but becomes quite observable when using the longer hue fix-up.

Hue Interpolation

Hue interpolation, along with fix-ups, is more generally covered in Hue Interpolation.

Normally, two colors with defined hues will have a shorter and longer arc length between the two hue angles.

Shorter Hue

>>> Color.interpolate(['red', 'blue'], space='hsl', hue='shorter')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d6b910>

Longer Hue

>>> Color.interpolate(['red', 'blue'], space='hsl', hue='longer')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09f028d0>

ColorAide and CSS apply hue fix-ups to ensure proper interpolation occurs along a given hue arc in the desired way. ColorAide's default linear interpolation waits until right before the actual interpolation takes place to resolve any and all undefined channels. This means that hue fix-ups are applied while hues are still undefined making it impossible to define a shorter or longer arc. This means that when a hue is undefined, whether longer or shorter is chosen, the result will be the same. Ultimately, ColorAide takes the stance that undefined hues cannot have an arc between itself and another hue as their is no hue to compare against.

CSS, on the other hand, resolves undefined hues before hue fix-ups are applied. This means that as long as the other hue is defined, the undefined hue will take on the value of the defined hue before the fix-up. With both hues defined this creates pseudo arc lengths (both shorter and longer) between the two hues. This subtle difference makes a large impact when evaluating longer hue interpolations. Instead of interpolating an undefined hue and a defined hue, CSS actually interpolates between either a shorter angel difference of 0Ëš or a longer angle difference of 360Ëš.

CSS Longer

>>> Color.interpolate(['hsl(0 75 50)', 'hsl(none 0 50)'], space='hsl', method='css-linear', hue='longer')
<coloraide.interpolate.css_linear.InterpolatorCSSLinear object at 0x7fbb0ad8f110>

ColorAide Longer

>>> Color.interpolate(['hsl(0 75 50)', 'hsl(none 0 50)'], space='hsl', method='linear', hue='longer')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09df4450>

There may be arguments as to why some feel CSS's approach is more or less appropriate, but our desire is only to clarify the differences and make known why our default is the way it is. If a CSS compatible linear interpolation is needed, then css-linear can be specified as the interpolation method. If css-linear is desired as the default approach, the Color object can be subclassed and configured to do so.

>>> from coloraide import Color as Base
>>> class Color(Base):
...     INTERPOLATOR = 'css-linear'
... 
>>> Color.interpolate(['red', 'transparent', 'blue'], space='hsl', hue='longer')
<coloraide.interpolate.css_linear.InterpolatorCSSLinear object at 0x7fbb09ffd690>

Piecewise Interpolation

Piecewise interpolation takes the idea of linear interpolation and then applies it to multiple colors. As drawing a straight line through a series of points greater than two can be difficult to achieve, piecewise interpolation creates straight lines between each color in a chain of colors.

Piecewise Interpolation

When the interpolate method receives more that two colors, the interpolation will utilize piecewise interpolation and interpolation will be broken up between each pair of colors. The function, just like when interpolating between two colors, still operates by default in the domain of [0, 1], only it will now apply to the entire range of colors.

Piecewise interpolation simply breaks up a series of data points into segments in order to apply interpolation individually on each segment.

>>> Color.interpolate(['black', 'red', 'white'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d546d0>

This approach generally works well, but since the placement of colors may not be in a straight line, you will often have pivot points and the transition may not be quite as smooth at these locations.

Continuous Interpolation

Continuous interpolation is registered in Color by Default

In this document, we use the term "continuous" in two ways when talking about interpolation: continuous vs discrete and the interpolation method whose literal name is continuous.

The interpolate method only creates continuous interpolations, meaning that for any point along the interpolation line, you will get a unique color. Continuous interpolation in this sense directly contrasts with with discrete interpolation which provides quantized color results where multiple inputs are associated with a limited set of colors along the interpolation line.

The continuous interpolation method is simply a piecewise, linear interpolation method that interpolates defined channels continuously across one more undefined channels.

Normal, piecewise interpolation only considers a single segments under interpolation at a time. When channels are undefined, the undefined channel on one end of a segment will adopt the value of the other defined channel on the other side of the segment, and if that other channel is also undefined, then any color interpolated between the two will also have no defined value for the channel. This approach to interpolation never considers any context beyond the segment it is looking at.

The continuous interpolation method is a linear piecewise approach created for ColorAide that will actually interpolate through undefined channels, using context from all the colors to be interpolated. What this means is that if you have multiple colors, and one or more of the colors have the same channel undefined, the colors with that channel defined will have those values interpolated across the undefined gaps across all the segments. This is probably better illustrated with an example.

In this example, we have 3 colors. The end colors both define lightness, but the middle color is undefined. We can see when we use normal, linear piecewise interpolation that we get a discontinuity. But with continuous linear interpolation, we get a smooth interpolation of the lightness through the undefined channel.

>>> colors = [
...     Color('oklab', [0, 0, 0]),
...     Color('oklab', [NaN, -0.03246, -0.31153]),
...     Color('oklab', [1, 0, 0])
... ]
>>> Color.interpolate(colors, space='oklab', method='linear')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09fe8d50>
>>> Color.interpolate(colors, space='oklab', method='continuous')
<coloraide.interpolate.continuous.InterpolatorContinuous object at 0x7fbb09e9f7d0>

Now, if have colors on the side that are not between two defined colors, all those colors will adopt the defined value of the one that is defined. This time we have a single color with all components defined, but all the colors to the left are missing the lightness. All colors with the undefined lightness will assume the lightness of the defined color.

>>> colors = [
...     Color('oklab', [NaN, 0.22486, 0.12585]),
...     Color('oklab', [NaN, -0.1403, 0.10768]),
...     Color('oklab', [0.45201, -0.03246, -0.31153])
... ]
>>> Color.interpolate(colors, space='oklab', method='linear')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb0a002190>
>>> Color.interpolate(colors, space='oklab', method='continuous')
<coloraide.interpolate.continuous.InterpolatorContinuous object at 0x7fbb09c43290>

Cubic Spline Interpolation

Linear interpolation is nice because it is easy to implement, and due to its straight forward nature, pretty fast. With that said, it doesn't always have the smoothest transitions. It turns out that there are other piecewise ways to interpolate that can yield smoother results.

Inspired by some efforts seen on the web and in the great JavaScript library Culori, ColorAide implements a number of spline based interpolation methods.

Because splines require taking into account more than two colors at a time, all spline based interpolation methods are built off of the continuous interpolation approach of handling undefined values.

B-Spline

B-Spline interpolation is registered in Color by Default

B-spline

B-spline is a piecewise spline similar to Bezier curves. It utilizes "control points" that help shape the interpolation path through a series of colors. Like Bezier Curves, the path does not pass through the control points, but it is clamped at the start and end. Essentially, the interpolation path passes through both end colors and bends that path along the way towards the other colors being used as control points.

It can be used by specifying bspline as the interpolation method.

>>> Color.interpolate(['red', 'green', 'blue', 'orange'], method='bspline')
<coloraide.interpolate.bspline.InterpolatorBSpline object at 0x7fbb0a093e50>

Natural

Natural interpolation is registered in Color by Default

Natural

The "natural" spline is the same as the B-spline approach except an algorithm is applied that uses the colors as data points and calculates new control points such that the interpolation passes through all the data points. This means that the path will pass through all the colors. The resultant spline has the continuity and properties of a natural spline, hence the name.

One down side is that it can overshoot or undershoot a bit, and can occasionally cause the interpolation path to pass out of gamut if interpolating on an edge.

It can be used by specifying natural as the interpolation method.

>>> Color.interpolate(['red', 'green', 'blue', 'orange'], method='natural')
<coloraide.interpolate.bspline_natural.InterpolatorNaturalBSpline object at 0x7fbb09ff3b50>

Monotone

Monotone interpolation is registered in Color by Default

Monotone

The "monotone" spline is a piecewise interpolation spline that passes through all its data points and helps to preserve monotonicity. As far as we are concerned, the important thing to note is that it greatly reduces any overshoot or undershoot in the interpolation.

>>> Color.interpolate(['red', 'green', 'blue', 'orange'], method='monotone')
<coloraide.interpolate.monotone.InterpolatorMonotone object at 0x7fbb09d86190>

Catmull-Rom

Catmull-Rom interpolation is not registered in Color by Default

Catmull-Rom

Lastly, the Catmull-Rom spline is another "interpolating" spline that passes through all of its data points, similar to the "natural" spline, but it but does not share the same continuity and properties of a "natural" spline.

Much like the "natural" spline, it can overshoot or undershoot.

Catmull-Rom is not registered by default, but can be registered as shown below and then used by specifying catrom as the interpolation method.

>>> from coloraide import Color
>>> from coloraide.interpolate.catmull_rom import CatmullRom
>>> class Custom(Color): ...
... 
>>> Custom.register(CatmullRom())
>>> Custom.interpolate(['red', 'green', 'blue', 'orange'], method='catrom')
<coloraide.interpolate.catmull_rom.InterpolatorCatmullRom object at 0x7fbb09df50d0>

Discrete Interpolation

New 2.5

So far, we've only shown examples of continuous interpolation methods. To clarify, we are using "continuous" in a slightly different way than we discussed earlier. When we say "continuous" here, we simply mean that the colors in the interpolation smoothly transition from one color to the other. But when creating charts or graphs, some times you'd like to categorize data such that a range of values correspond to a specific color. For this, we can use discrete, which like intrpolate, returns an interpolation object, but the the ranges will be discrete.

By default, ranges are calculated directly form the input colors. So if you had three colors, the interpolation would be broken up into 3 ranges. Compare this with the the "continuous" interpolation we methods we showed earlier.

>>> Color.discrete(['red', 'green', 'blue'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb0a092990>
>>> Color.interpolate(['red', 'green', 'blue'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09dec550>

If we specify step, we can create a larger or smaller color scale using the input colors to interpolate the new color scale. And we can use any of the aforementioned interpolation methods to help generate this new discrete scale.

>>> Color.discrete(['red', 'green', 'blue'], steps=5, method='catrom')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb0a001110>

What makes this really useful is if you combine it with custom domains to process data. By default, the domain is [0, 1], but we can change this to directly correlate the data with our quantized color samples. For instance, let's use a series of discrete colors to represent temperature. Additionally, let's use domain to associate a temperature ranges with the given colors. Now when we input a temperature value, it will align with our discrete color scale.

>>> i = Color.discrete(['blue', 'green', 'yellow', 'orange', 'red'], domain=[-32, 32, 60, 85, 95])
>>> i(-32)
color(--oklab 0.45201 -0.03246 -0.31153 / 1)
>>> i(40)
color(--oklab 0.51975 -0.1403 0.10768 / 1)
>>> i(87)
color(--oklab 0.79269 0.05661 0.16138 / 1)
>>> i(100)
color(--oklab 0.62796 0.22486 0.12585 / 1)
>>> i
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb0c76f510>

Additionally, color scales can be limited using the padding parameter.

>>> Color.discrete(['blue', 'green', 'yellow', 'orange', 'red'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09e28c90>
>>> Color.discrete(['blue', 'green', 'yellow', 'orange', 'red'], padding=[0.25, 0])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09ff3e90>

As discrete() is built on steps(), it can take all the same arguments. Check out steps() to learn more.

Hue Interpolation

In interpolation, hues are handled special allowing us to control the way in which hues are evaluated. By default, the shortest angle between two hues is targeted for interpolation, but the hue option allows us to redefine this behavior in a number of interesting ways: shorter, longer, increasing, decreasing, and specified. These hue "fix-ups" identify all possible ways in which we can interpolate a hue and come from the CSS level 4 specification.

Specified

The specified fix-up was at one time specified in the CSS Color Level 4 specification, but is no longer mentioned there. While CSS no longer supports this hue fix-up, we still do. specified simply does not apply any hue fix-up and will use hues as specified, hence the name.

To help visualize the different hue methods, consider the following evaluation between hsl(270 50 40) and hsl(780 100 40). Below we will demonstrate each of the different hue evaluations and explain what it is that they do.

shorter interpolates along the shortest arc length after normalizing the hues.

Shorter

>>> Color.interpolate(
...     ["hsl(270 50 40)", "hsl(780 100 40)"],
...     space='hsl',
...     hue="shorter"
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09df51d0>

longer interpolates along the longest arc length after normalizing the hues.

Longer

>>> Color.interpolate(
...     ["hsl(270 50 40)", "hsl(780 100 40)"],
...     space='hsl',
...     hue="longer"
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d879d0>

increasing interpolates counter clockwise (after normalizing the hues), such that the hues are increasing.

Increasing

>>> Color.interpolate(
...     ["hsl(270 50 40)", "hsl(780 100 40)"],
...     space='hsl',
...     hue="increasing"
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09dd3050>

decreasing interpolates clockwise (after normalizing the hues), such that the hues are decreasing.

Decreasing

>>> Color.interpolate(
...     ["hsl(270 50 40)", "hsl(780 100 40)"],
...     space='hsl',
...     hue="decreasing"
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09df4210>

specified interpolates from the first color to the second color without applying any hue fix-ups.

Specified

>>> Color.interpolate(
...     ["hsl(270 50 40)", "hsl(780 100 40)"],
...     space='hsl',
...     hue="specified"
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09e640d0>

In general, achromatic colors cannot have an arc length between them and other color. When applying linear interpolation between an achromatic color (or a color which simply does not define the hue), the applied hue fix-up will have little effect. With that said, when using CSS linear interpolation, the algorithm is a little different and it essentially creates pseudo arcs between color pairs with undefined hues and defined hues. Only with the longer hue fix-up does this become apparent. Below we compare CSS linear interpolation to our our default linear interpolation.

CSS Longer

>>> Color.interpolate(['hsl(0 75 50)', 'hsl(none 0 50)'], space='hsl', method='css-linear', hue='longer')
<coloraide.interpolate.css_linear.InterpolatorCSSLinear object at 0x7fbb09d30310>

ColorAide Longer

>>> Color.interpolate(['hsl(0 75 50)', 'hsl(none 0 50)'], space='hsl', method='linear', hue='longer')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09df46d0>

Interpolating with Alpha

Interpolating color channels is pretty straight forward and uses traditional linear interpolation logic, but when introducing transparency to a color, interpolation uses a concept known as premultiplication which alters the normal interpolation process.

Premultiplication is a technique that tends to produce better results when two colors have differing transparency. It essentially accounts for the transparency and uses it to weight how may a given color channel will contribute to the interpolation. A more transparent color's channels will naturally contribute less.

Consider the following example. Normally, when transitioning to a "transparent" color, the colors will be more gray during the transition. This is because transparent is actually black. But when using premultiplication, the transition looks just as one would expect as the transparent color's channels are weighted less due to the high transparency.

>>> Color.interpolate(['white', 'transparent'], space='srgb', premultiplied=False)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d85490>
>>> Color.interpolate(['white', 'transparent'], space='srgb')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d58fd0>

As a final example, below we have an opaque orange and a blue that is quite transparent. Logically, the blue shouldn't have as big an affect on the overall color as it is so faint, and yet, in the un-premultiplied example, when mixing the colors equally, we see that the resultant color is also equally influenced by the hue of both colors. In the premultiplied example, we see that orange is still quite dominant at 50% as it is fully opaque.

>>> Color('orange').mix(Color('blue').set('alpha', 0.25), space='srgb', premultiplied=False)
color(srgb 0.5 0.32353 0.5 / 0.625)
>>> Color('orange').mix(Color('blue').set('alpha', 0.25), space='srgb')
color(srgb 0.8 0.51765 0.2 / 0.625)

If we interpolate it, we can see the difference in transition.

>>> Color.interpolate(['orange', Color('blue').set('alpha', 0.25)], space='srgb', premultiplied=False)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09c4ba10>
>>> Color.interpolate(['orange', Color('blue').set('alpha', 0.25)], space='srgb')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d5b8d0>

There may be some cases where it is desired to use no premultiplication in alpha blending. One could simply be that you need to mimic the same behavior of a system that does not use premultiplied interpolation. If so, simply set premultiplied to False as shown above.

Mixing

Interpolation Options

Any options not consumed by mix will be passed to the underlying interpolation function. This includes options like hue, progress, etc.

The mix function is built on top of the interpolate function and provides a simple, quick, and intuitive simple mixing of two colors. Just pass in a color to mix with the base color, and you'll get an equal mix of the two.

>>> Color("red").mix(Color("blue"))
color(--oklab 0.53998 0.0962 -0.09284 / 1)

By default, colors are mixed at 50%, but the percentage can be controlled. Here we mix the color blue into the color red at 20%. With blue at 20% and red at 80%, this gives us a more reddish color.

>>> Color("red").mix(Color("blue"), 0.2)
color(--oklab 0.59277 0.1734 0.03837 / 1)

As with all interpolation based functions, if needed, a different color space can be specified with the space parameter or even a different interpolation method via method. mix accepts all the same parameters used in interpolate, though concepts like stops and hints are not allowed with mixing.

>>> Color("red").mix(Color("blue"), space="hsl", method='bspline')
color(--hsl -60 1 0.5 / 1)

Mix can also accept a string and will create the color for us which is great if we don't need to work with the second color afterwards.

>>> Color("red").mix("blue", 0.2)
color(--oklab 0.59277 0.1734 0.03837 / 1)

Mixing will always return a new color unless in_place is set True.

Steps

Interpolation Options

Any options not consumed by mix will be passed to the underlying interpolation function. This includes options like hue, progress, etc.

The steps method provides an intuitive interface to create lists of discrete colors. Like mixing, it is also built on interpolate. Just provide two or more colors, and specify how many steps are wanted.

>>> Color.steps(["red", "blue"], steps=10)
[color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.60841 0.19627 0.07725 / 1), color(--oklab 0.58886 0.16768 0.02865 / 1), color(--oklab 0.56931 0.13909 -0.01995 / 1), color(--oklab 0.54976 0.1105 -0.06854 / 1), color(--oklab 0.53021 0.08191 -0.11714 / 1), color(--oklab 0.51066 0.05332 -0.16574 / 1), color(--oklab 0.49111 0.02473 -0.21433 / 1), color(--oklab 0.47156 -0.00387 -0.26293 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1)]

If desired, multiple colors can be provided, and steps will be returned for all the interpolated segments. When interpolating multiple colors, piecewise interpolation is used (which is covered in more detail later).

>>> Color.steps(["red", "orange", "yellow", "green"], steps=10)
[color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.68287 0.16878 0.13769 / 1), color(--oklab 0.73778 0.1127 0.14954 / 1), color(--oklab 0.79269 0.05661 0.16138 / 1), color(--oklab 0.85112 0.01395 0.17378 / 1), color(--oklab 0.90955 -0.02871 0.18617 / 1), color(--oklab 0.96798 -0.07137 0.19857 / 1), color(--oklab 0.81857 -0.09435 0.16827 / 1), color(--oklab 0.66916 -0.11732 0.13797 / 1), color(--oklab 0.51975 -0.1403 0.10768 / 1)]

Steps can also be configured to return colors based on a maximum Delta E distance. This means you can ensure the distance between all colors is no greater than a certain value.

In this example, we specify the color color(display-p3 0 1 0) and interpolate steps between red. The result gives us an array of colors, where the distance between any two colors should be no greater than the Delta E result of 10.

>>> Color.steps(
...     [Color("display-p3", [0, 1, 0]), "red"],
...     space="lch",
...     out_space="srgb",
...     max_delta_e=10
... )
[color(srgb -0.5116 1.0183 -0.31067 / 1), color(srgb -0.4504 0.99903 -0.32673 / 1), color(srgb -0.37655 0.97943 -0.33694 / 1), color(srgb -0.27847 0.95946 -0.34286 / 1), color(srgb -0.09291 0.9391 -0.34554 / 1), color(srgb 0.23528 0.91833 -0.34574 / 1), color(srgb 0.34809 0.89715 -0.34401 / 1), color(srgb 0.42823 0.87552 -0.34098 / 1), color(srgb 0.49308 0.85343 -0.33745 / 1), color(srgb 0.54849 0.83088 -0.33349 / 1), color(srgb 0.59727 0.80784 -0.32909 / 1), color(srgb 0.64097 0.7843 -0.32423 / 1), color(srgb 0.6806 0.76025 -0.3189 / 1), color(srgb 0.71679 0.73568 -0.3131 / 1), color(srgb 0.74999 0.71057 -0.30681 / 1), color(srgb 0.78053 0.6849 -0.30002 / 1), color(srgb 0.80865 0.65865 -0.29271 / 1), color(srgb 0.83451 0.6318 -0.28486 / 1), color(srgb 0.85826 0.60433 -0.27644 / 1), color(srgb 0.87999 0.57619 -0.26744 / 1), color(srgb 0.89978 0.54736 -0.25782 / 1), color(srgb 0.9177 0.51777 -0.24752 / 1), color(srgb 0.9338 0.48735 -0.2365 / 1), color(srgb 0.9481 0.456 -0.22467 / 1), color(srgb 0.96064 0.4236 -0.21195 / 1), color(srgb 0.97145 0.38994 -0.19821 / 1), color(srgb 0.98055 0.35476 -0.18326 / 1), color(srgb 0.98796 0.31761 -0.16684 / 1), color(srgb 0.99369 0.27779 -0.14854 / 1), color(srgb 0.99777 0.23405 -0.12767 / 1), color(srgb 1.0002 0.18378 -0.10158 / 1), color(srgb 1.0009 0.11978 -0.06417 / 1), color(srgb 1 0 0 / 1)]

max_steps can be used to limit the results of max_delta_e in case result balloons to an unexpected size. Obviously, this affects the Delta E between the colors inversely. It should be noted that steps are injected equally between every color when satisfying a max Delta E limit in order to avoid shifting the midpoint. In some cases, in order to satisfy both the max_delta_e and the max_steps requirement, the number of steps may even be clipped such that they are less than the max_steps limit. max_steps is set to 1000 by default, but can be set to None if no limit is desired.

>>> Color.steps(
...     [Color("display-p3", [0, 1, 0]), "red"],
...     space="lch",
...     out_space="srgb",
...     max_delta_e=10,
...     max_steps=10
... )
[color(srgb -0.5116 1.0183 -0.31067 / 1), color(srgb -0.09291 0.9391 -0.34554 / 1), color(srgb 0.49308 0.85343 -0.33745 / 1), color(srgb 0.6806 0.76025 -0.3189 / 1), color(srgb 0.80865 0.65865 -0.29271 / 1), color(srgb 0.89978 0.54736 -0.25782 / 1), color(srgb 0.96064 0.4236 -0.21195 / 1), color(srgb 0.99369 0.27779 -0.14854 / 1), color(srgb 1 0 0 / 1)]

When specifying a max_delta_e, steps will function as a minimum required steps and will push the delta even smaller if the required steps is greater than the calculated steps via the maximum Delta E limit.

>>> Color.steps(
...     [Color("display-p3", [0, 1, 0]), "red"],
...     space="lch",
...     out_space="srgb",
...     max_delta_e=10,
...     steps=50
... )
[color(srgb -0.5116 1.0183 -0.31067 / 1), color(srgb -0.47276 1.0057 -0.32192 / 1), color(srgb -0.42946 0.99307 -0.33039 / 1), color(srgb -0.37992 0.98024 -0.33661 / 1), color(srgb -0.32082 0.96725 -0.341 / 1), color(srgb -0.24447 0.9541 -0.34385 / 1), color(srgb -0.1198 0.94078 -0.34542 / 1), color(srgb 0.15996 0.92729 -0.34592 / 1), color(srgb 0.26558 0.91362 -0.3455 / 1), color(srgb 0.33665 0.89976 -0.34431 / 1), color(srgb 0.3931 0.88572 -0.34248 / 1), color(srgb 0.44104 0.8715 -0.34036 / 1), color(srgb 0.48324 0.85707 -0.33806 / 1), color(srgb 0.52118 0.84244 -0.33557 / 1), color(srgb 0.55582 0.82761 -0.33289 / 1), color(srgb 0.58776 0.81258 -0.33002 / 1), color(srgb 0.61745 0.79733 -0.32696 / 1), color(srgb 0.64519 0.78187 -0.32371 / 1), color(srgb 0.67123 0.76619 -0.32025 / 1), color(srgb 0.69575 0.75029 -0.31659 / 1), color(srgb 0.7189 0.73416 -0.31273 / 1), color(srgb 0.74079 0.7178 -0.30866 / 1), color(srgb 0.76151 0.7012 -0.30437 / 1), color(srgb 0.78113 0.68437 -0.29987 / 1), color(srgb 0.79972 0.66729 -0.29515 / 1), color(srgb 0.81733 0.64995 -0.2902 / 1), color(srgb 0.834 0.63236 -0.28502 / 1), color(srgb 0.84977 0.6145 -0.2796 / 1), color(srgb 0.86467 0.59636 -0.27393 / 1), color(srgb 0.87872 0.57794 -0.26801 / 1), color(srgb 0.89194 0.55922 -0.26182 / 1), color(srgb 0.90434 0.54018 -0.25536 / 1), color(srgb 0.91596 0.52082 -0.2486 / 1), color(srgb 0.92679 0.50111 -0.24154 / 1), color(srgb 0.93686 0.48103 -0.23415 / 1), color(srgb 0.94616 0.46054 -0.22641 / 1), color(srgb 0.95471 0.43961 -0.2183 / 1), color(srgb 0.96252 0.41819 -0.20979 / 1), color(srgb 0.96959 0.39623 -0.20082 / 1), color(srgb 0.97593 0.37364 -0.19136 / 1), color(srgb 0.98155 0.35032 -0.18134 / 1), color(srgb 0.98644 0.32615 -0.17067 / 1), color(srgb 0.99062 0.30093 -0.15926 / 1), color(srgb 0.99409 0.27439 -0.14694 / 1), color(srgb 0.99685 0.24615 -0.13353 / 1), color(srgb 0.99891 0.21556 -0.11841 / 1), color(srgb 1.0002 0.18152 -0.10034 / 1), color(srgb 1.0009 0.14177 -0.07755 / 1), color(srgb 1.0008 0.09013 -0.04535 / 1), color(srgb 1 0 0 / 1)]

steps uses the color class's default ∆E method to calculate max ∆E, the current default ∆E being ∆E*ab. While using something like ∆E*00 is far more accurate, it is a much more expensive operation. If desired, the class's default ∆E can be changed via subclassing the color object and and changing DELTA_E class variable or by manually specifying the method via the delta_e parameter.

>>> Color.steps(
...     [Color("display-p3", [0, 1, 0]), "red"],
...     space="lch",
...     out_space="srgb",
...     max_delta_e=10,
...     delta_e="76"
... )
[color(srgb -0.5116 1.0183 -0.31067 / 1), color(srgb -0.4504 0.99903 -0.32673 / 1), color(srgb -0.37655 0.97943 -0.33694 / 1), color(srgb -0.27847 0.95946 -0.34286 / 1), color(srgb -0.09291 0.9391 -0.34554 / 1), color(srgb 0.23528 0.91833 -0.34574 / 1), color(srgb 0.34809 0.89715 -0.34401 / 1), color(srgb 0.42823 0.87552 -0.34098 / 1), color(srgb 0.49308 0.85343 -0.33745 / 1), color(srgb 0.54849 0.83088 -0.33349 / 1), color(srgb 0.59727 0.80784 -0.32909 / 1), color(srgb 0.64097 0.7843 -0.32423 / 1), color(srgb 0.6806 0.76025 -0.3189 / 1), color(srgb 0.71679 0.73568 -0.3131 / 1), color(srgb 0.74999 0.71057 -0.30681 / 1), color(srgb 0.78053 0.6849 -0.30002 / 1), color(srgb 0.80865 0.65865 -0.29271 / 1), color(srgb 0.83451 0.6318 -0.28486 / 1), color(srgb 0.85826 0.60433 -0.27644 / 1), color(srgb 0.87999 0.57619 -0.26744 / 1), color(srgb 0.89978 0.54736 -0.25782 / 1), color(srgb 0.9177 0.51777 -0.24752 / 1), color(srgb 0.9338 0.48735 -0.2365 / 1), color(srgb 0.9481 0.456 -0.22467 / 1), color(srgb 0.96064 0.4236 -0.21195 / 1), color(srgb 0.97145 0.38994 -0.19821 / 1), color(srgb 0.98055 0.35476 -0.18326 / 1), color(srgb 0.98796 0.31761 -0.16684 / 1), color(srgb 0.99369 0.27779 -0.14854 / 1), color(srgb 0.99777 0.23405 -0.12767 / 1), color(srgb 1.0002 0.18378 -0.10158 / 1), color(srgb 1.0009 0.11978 -0.06417 / 1), color(srgb 1 0 0 / 1)]
>>> Color.steps(
...     [Color("display-p3", [0, 1, 0]), "red"],
...     space="lch",
...     out_space="srgb",
...     max_delta_e=10,
...     delta_e="2000"
... )
[color(srgb -0.5116 1.0183 -0.31067 / 1), color(srgb -0.37655 0.97943 -0.33694 / 1), color(srgb -0.09291 0.9391 -0.34554 / 1), color(srgb 0.34809 0.89715 -0.34401 / 1), color(srgb 0.49308 0.85343 -0.33745 / 1), color(srgb 0.59727 0.80784 -0.32909 / 1), color(srgb 0.6806 0.76025 -0.3189 / 1), color(srgb 0.74999 0.71057 -0.30681 / 1), color(srgb 0.80865 0.65865 -0.29271 / 1), color(srgb 0.85826 0.60433 -0.27644 / 1), color(srgb 0.89978 0.54736 -0.25782 / 1), color(srgb 0.9338 0.48735 -0.2365 / 1), color(srgb 0.96064 0.4236 -0.21195 / 1), color(srgb 0.98055 0.35476 -0.18326 / 1), color(srgb 0.99369 0.27779 -0.14854 / 1), color(srgb 1.0002 0.18378 -0.10158 / 1), color(srgb 1 0 0 / 1)]

And much like interpolate, we can use stops and hints and any of the other supported interpolate features as well.

>>> Color.steps(['orange', stop('purple', 0.25), 'green'], method='bspline', steps=10)
[color(--oklab 0.79269 0.05661 0.16138 / 1), color(--oklab 0.63434 0.09861 0.05147 / 1), color(--oklab 0.51731 0.10434 -0.01701 / 1), color(--oklab 0.48698 0.08246 -0.02298 / 1), color(--oklab 0.47842 0.05764 -0.01527 / 1), color(--oklab 0.4775 0.02611 0.00011 / 1), color(--oklab 0.48271 -0.01079 0.02163 / 1), color(--oklab 0.49251 -0.05172 0.04775 / 1), color(--oklab 0.50536 -0.09534 0.07695 / 1), color(--oklab 0.51975 -0.1403 0.10768 / 1)]

Masking

If desired, we can mask off specific channels that we do not wish to interpolate. Masking works by cloning the color and setting the specified channels as undefined (internally set to NaN). When interpolating, if one color's channel has a NaN, the other color's channel will be used as the result, keeping that channel at a constant value. If both colors have a NaN for the same channel, then NaN will be returned.

Magic Behind NaN

There are times when NaN values can happen naturally, such as with achromatic colors with hues. To learn more, check out Undefined Handling/NaN Handling.

In the following example, we have a base color of lch(52% 58.1 22.7) which we then interpolate with lch(56% 49.1 257.1). We then mask off the second color's channels except for hue. Applying this logic, we will end up with a range of colors that maintains the same lightness and chroma as the first color, but with different hues. We can see as we step through the colors that only the hue is interpolated.

>>> i = Color.interpolate(
...     ["lch(52% 58.1 22.7)", Color("lch(56% 49.1 257.1)").mask(['lightness', 'chroma', 'alpha'])],
...     space="lch"
... )
>>> [i(x/10).to_string() for x in range(10)]
['lch(52 58.1 22.7)', 'lch(52 58.1 10.14)', 'lch(52 58.1 357.58)', 'lch(52 58.1 345.02)', 'lch(52 58.1 332.46)', 'lch(52 58.1 319.9)', 'lch(52 58.1 307.34)', 'lch(52 58.1 294.78)', 'lch(52 58.1 282.22)', 'lch(52 58.1 269.66)']

You can also create inverted masks. An inverted mask will mask all except the specified channel.

>>> i = Color.interpolate(
...     ["lch(52% 58.1 22.7)", Color("lch(56% 49.1 257.1)").mask('hue', invert=True)],
...     space="lch"
... )
>>> [i(x/10).to_string() for x in range(10)]
['lch(52 58.1 22.7)', 'lch(52 58.1 10.14)', 'lch(52 58.1 357.58)', 'lch(52 58.1 345.02)', 'lch(52 58.1 332.46)', 'lch(52 58.1 319.9)', 'lch(52 58.1 307.34)', 'lch(52 58.1 294.78)', 'lch(52 58.1 282.22)', 'lch(52 58.1 269.66)']

Easing Functions

When interpolating, whether using linear interpolation or something like B-Spline interpolation, the transitioning between colors is always linear in time, even if the path to those colors is not. For example, if you are interpolating between 2 colors and you request a 0.5 point on that line, it will always be in the middle. This is because, no matter how crooked the path, the rate of change on that path is always linear.

By default, ColorAide uses linear transitions when interpolating, but there are times that a different, more dynamic transition may be desired. This can be achieved by using the progress parameter on any of the interpolation related functions provided by ColorAide.

progress accepts an easing function that takes a single time input and returns a new time input. This allows for a user to augment the rate of change when transitioning from one color to another. Inputs are almost always between 0 - 1 unless extrapolate is enabled and the user has manually input a range beyond 0 - 1. Even a change in domain will not affect the range as once the domain is accounted for, internally the domain [0, 1] is used.

ColorAide provides 5 basic easing functions out of the box along with cubic_bezier which is used to create all of the aforementioned easing function except linear, which simply returns what is given as an input.

Create Your Own Cubic Bezier Easings Online: https://cubic-bezier.com

More Common Cubic Bezier Easings

The following were all acquired from from https://matthewlein.com/tools/ceaser.js.

ease_in_quad = cubic_bezier(0.550, 0.085, 0.680, 0.530)
ease_in_cubic = cubic_bezier(0.550, 0.055, 0.675, 0.190)
ease_in_quart = cubic_bezier(0.895, 0.030, 0.685, 0.220)
ease_in_quint = cubic_bezier(0.755, 0.050, 0.855, 0.060)
ease_in_sine = cubic_bezier(0.470, 0.000, 0.745, 0.715)
ease_in_expo = cubic_bezier(0.950, 0.050, 0.795, 0.035)
ease_in_circ = cubic_bezier(0.600, 0.040, 0.980, 0.335)
ease_in_back = cubic_bezier(0.600, -0.280, 0.735, 0.045)

ease_out_quad = cubic_bezier(0.250, 0.460, 0.450, 0.940)
ease_out_cubic = cubic_bezier(0.215, 0.610, 0.355, 1.000)
ease_out_quart = cubic_bezier(0.165, 0.840, 0.440, 1.000)
ease_out_quint = cubic_bezier(0.230, 1.000, 0.320, 1.000)
ease_out_sine = cubic_bezier(0.390, 0.575, 0.565, 1.000)
ease_out_expo = cubic_bezier(0.190, 1.000, 0.220, 1.000)
ease_out_circ = cubic_bezier(0.075, 0.820, 0.165, 1.000)
ease_out_back = cubic_bezier(0.175, 0.885, 0.320, 1.275)

ease_in_out_quad = cubic_bezier(0.455, 0.030, 0.515, 0.955)
ease_in_out_cubic = cubic_bezier(0.645, 0.045, 0.355, 1.000)
ease_in_out_quart = cubic_bezier(0.770, 0.000, 0.175, 1.000)
ease_in_out_quint = cubic_bezier(0.860, 0.000, 0.070, 1.000)
ease_in_out_sine = cubic_bezier(0.445, 0.050, 0.550, 0.950)
ease_in_out_expo = cubic_bezier(1.000, 0.000, 0.000, 1.000)
ease_in_out_circ = cubic_bezier(0.785, 0.135, 0.150, 0.860)
ease_in_out_back = cubic_bezier(0.680, -0.550, 0.265, 1.550)

Linear

Ease

Ease In

Ease Out

Ease In/Out

Ease Cubic Bezier

Here, we are using the default "ease in" and "ease out" easing functions provided by ColorAide.

>>> from coloraide import ease_in, ease_out
>>> Color.interpolate(
...     ["green", "blue"],
...     progress=ease_in
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d30910>
>>> Color.interpolate(
...     ["green", "blue"]
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d5a150>
>>> Color.interpolate(
...     ["green", "blue"],
...     progress=ease_out
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09e65410>

Additionally, easing functions can be injected inline which allows a user to control how easing is performed between specific sub-interpolations within piecewise interpolation.

>>> Color.interpolate(["red", "green", ease_out, "blue"])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09e9ef50>

ColorAide even lets you apply easing functions to specific channels, though they can only be done this way for the entire operation. This can be done to one or more channels at a time. Below, we apply an exponential "ease in" to alpha while allowing all other channels to interpolate normally.

>>> ease_in_expo = cubic_bezier(0.950, 0.050, 0.795, 0.035)
>>> Color.interpolate(
...     ["lch(50% 50 0)", "lch(90% 50 260 / 0.5)"],
...     progress={
...         'alpha': ease_in_expo
...     }
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09ff8d50>

We can also set all the channels to an easing function via all and then override specific channels. In this case, we exponentially "ease out" on all channels except the red channel, which we then force to be linear.

>>> ease_out_expo = cubic_bezier(0.190, 1.000, 0.220, 1.000)
>>> Color.interpolate(
...     ["color(srgb 0 1 1)", "color(srgb 1 0 0)"],
...     progress={
...         'all': ease_out_expo,
...         'r': linear
...     },
...     space='srgb'
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09df4310>

Color Stops and Hints

Color stops are the position where the transition to and from a color starts and ends. By default, color stops are evenly distributed within the domain of [0, 1], but if desired, these color stops can be shifted.

To specify color stops, simply wrap a color in a coloraide.stop object and specify the stop position. Stop positions will then cause the transition of the targeted color to be moved.

>>> from coloraide import stop
>>> Color.interpolate(['orange', 'purple', 'green'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09dd1c90>
>>> Color.interpolate(['orange', stop('purple', 0.25), 'green'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09ff65d0>

Color stops follow the rules as laid out in the CSS spec.

CSS gradients also have a concept of "hints". Hints essentially define the midpoint between two colors. Instead of reinventing the wheel, and further complicating the interface, we've decided to just demonstrate color hints with easing functions. The logic comes directly from the CSS spec.

Using the hint function, we can generate a midpoint easing method that moves the middle of the interpolation transition to the specified point which is relative to the two color stops it is between.

>>> from coloraide import hint
>>> Color.interpolate(['orange', 'purple', 'green'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09ff9950>
>>> Color.interpolate(['orange', hint(0.75), 'purple', 'green'])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09e64f10>

Padding

New 2.6

Particularly when interpolating a color scale, it can be useful to "resize" the area of the color scale being evaluated. This can generally be done using the padding parameter. Consider the following example using the ColorBrewer scale OrRd.

>>> scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
>>> Color.interpolate(scale, space='srgb')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09e2ae50>
>>> Color.discrete(scale, space='srgb', steps=5)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb0c76f510>
>>> Color.interpolate(scale, space='srgb', padding=0.25)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09c49890>
>>> Color.discrete(scale, space='srgb', steps=5, padding=0.25)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d86610>

Padding can be applied to both sides by specifying a single number, or it can be controlled per side by sending in a sequence of two values.

>>> scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
>>> Color.discrete(scale, space='srgb', steps=5, padding=[0.25, 0])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d54350>

Negative padding is allowed as well.

>>> scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
>>> Color.discrete(scale, space='srgb', steps=5, padding=[-0.25, 0])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d2d5d0>

If the result extends past the limits, extrapolate needs to be enabled or the values will be clamped to the ends.

>>> scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
>>> Color.discrete(scale, space='srgb', steps=5, padding=[1, 1])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb0a000990>
>>> Color.discrete(scale, space='srgb', steps=5, padding=[1, 1], extrapolate=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb0a001b90>

Domains

By default, interpolation has an input domain of [0, 1]. This domain applies to an entire interpolation, even ones that span multiple colors. Generally, this is sufficient and can be used to generate color scales, mixes, and steps in any way that a user needs. When generating colors that should align with data, custom domains can be quite helpful.

For instance, associating colors with temperature.

>>> i = Color.interpolate(
...     ['blue', 'green', 'yellow', 'orange', 'red'],
...     domain=[-32, 32, 60, 85, 95]
... )
>>> i(-32)
color(--oklab 0.45201 -0.03246 -0.31153 / 1)
>>> i(47)
color(--oklab 0.75988 -0.10337 0.15637 / 1)
>>> i(89)
color(--oklab 0.7268 0.12391 0.14717 / 1)
>>> i
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09ff3d10>

It should be noted that you are not constrained to provide the exact same amount of domain values as you have colors and can have differing amounts, but if you want to align specific colors to certain data points, then it helps.

>>> Color.interpolate(
...     ['blue', 'green', 'yellow', 'orange', 'red'],
...     domain=[-32, 32, 60, 85, 95]
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d330d0>
>>> Color.interpolate(
...     ['blue', 'green', 'yellow', 'orange', 'red'],
...     domain=[-32, 95]
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d599d0>

Lastly, domains must be specified in ascending order of values. If a value decreases in magnitude, it will assume the value that comes right before it. This means you cannot put a domain in reverse. If you need to reverse the order, just flip the color order and setup the domain accordingly.

>>> i = Color.interpolate(
...     ['blue', 'green', 'yellow', 'orange', 'red'],
...     domain=[-32, 32, 60, 20, 95]
... )
>>> i.domain
<bound method Interpolator.domain of <coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb0a0025d0>>
>>> i
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb0a0025d0>

Custom domains are most useful when working with discrete or interpolate directly, but you can use it in other methods like steps as well. As steps does not take data point inputs like interpolate, we do not need to use the temperature data as an input except to set the domain, but the steps will be generated with the same alignment relative to the domain range.

>>> Color.interpolate(
...     ['blue', 'green', 'yellow', 'orange', 'red'],
...     domain=[-32, 32, 60, 85, 95]
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09ff7dd0>
>>> Color.discrete(
...     ['blue', 'green', 'yellow', 'orange', 'red'],
...     domain=[-32, 32, 60, 85, 95]
... )
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09e66050>
>>> Color.steps(
...     ['blue', 'green', 'yellow', 'orange', 'red'],
...     steps=11,
...     domain=[-32, 32, 60, 85, 95]
... )
[color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.46546 -0.05386 -0.22834 / 1), color(--oklab 0.4789 -0.07526 -0.14516 / 1), color(--oklab 0.49234 -0.09666 -0.06197 / 1), color(--oklab 0.50578 -0.11806 0.02122 / 1), color(--oklab 0.51922 -0.13946 0.1044 / 1), color(--oklab 0.71505 -0.11027 0.14728 / 1), color(--oklab 0.91836 -0.079 0.18851 / 1), color(--oklab 0.90067 -0.02222 0.18429 / 1), color(--oklab 0.81162 0.04279 0.1654 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1)]

Wile you can technically feed domain into mix, it is probably not as useful. It will respect the domain alignment, but mix always accepts a percentage of [0, 1], regardless of the underlying domain.

Extrapolation

By default, ColorAide clamps the entire progress of an interpolation to always be within the domain ([0, 1] by default). In most cases, this is more what most user expects and why this is the default. It should be noted that this does not affect easing functions, as the clamping is done prior to any easing function calls.

If it is desired to extrapolate past 0 and 1, extrapolate can set to True on all interpolation methods.

>>> Color('red').mix('blue', 0.5)
color(--oklab 0.53998 0.0962 -0.09284 / 1)
>>> Color('red').mix('blue', -0.5, extrapolate=True)
color(--oklab 0.71593 0.35352 0.34453 / 1)

As a larger example, we can purposely interpolate over a range with values beyond 0 and 1. Here we extended the range to -0.5 and 1.5.

>>> offset, factor = 0.25, 1.5
>>> i = Color.interpolate(['red', 'blue'])
>>> Ramp([i((r * factor / 100) - offset) for r in range(101)])
[color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62796 0.22486 0.12585 / 1), color(--oklab 0.62708 0.22358 0.12366 / 1), color(--oklab 0.62444 0.21972 0.1171 / 1), color(--oklab 0.6218 0.21586 0.11054 / 1), color(--oklab 0.61916 0.212 0.10398 / 1), color(--oklab 0.61652 0.20814 0.09742 / 1), color(--oklab 0.61388 0.20428 0.09086 / 1), color(--oklab 0.61124 0.20042 0.0843 / 1), color(--oklab 0.6086 0.19656 0.07774 / 1), color(--oklab 0.60596 0.1927 0.07117 / 1), color(--oklab 0.60332 0.18884 0.06461 / 1), color(--oklab 0.60068 0.18498 0.05805 / 1), color(--oklab 0.59805 0.18112 0.05149 / 1), color(--oklab 0.59541 0.17726 0.04493 / 1), color(--oklab 0.59277 0.1734 0.03837 / 1), color(--oklab 0.59013 0.16954 0.03181 / 1), color(--oklab 0.58749 0.16568 0.02525 / 1), color(--oklab 0.58485 0.16182 0.01869 / 1), color(--oklab 0.58221 0.15796 0.01213 / 1), color(--oklab 0.57957 0.1541 0.00557 / 1), color(--oklab 0.57693 0.15024 -0.00099 / 1), color(--oklab 0.57429 0.14638 -0.00755 / 1), color(--oklab 0.57165 0.14252 -0.01411 / 1), color(--oklab 0.56901 0.13866 -0.02067 / 1), color(--oklab 0.56638 0.1348 -0.02723 / 1), color(--oklab 0.56374 0.13094 -0.0338 / 1), color(--oklab 0.5611 0.12708 -0.04036 / 1), color(--oklab 0.55846 0.12322 -0.04692 / 1), color(--oklab 0.55582 0.11936 -0.05348 / 1), color(--oklab 0.55318 0.1155 -0.06004 / 1), color(--oklab 0.55054 0.11164 -0.0666 / 1), color(--oklab 0.5479 0.10778 -0.07316 / 1), color(--oklab 0.54526 0.10392 -0.07972 / 1), color(--oklab 0.54262 0.10006 -0.08628 / 1), color(--oklab 0.53998 0.0962 -0.09284 / 1), color(--oklab 0.53735 0.09234 -0.0994 / 1), color(--oklab 0.53471 0.08848 -0.10596 / 1), color(--oklab 0.53207 0.08462 -0.11252 / 1), color(--oklab 0.52943 0.08076 -0.11908 / 1), color(--oklab 0.52679 0.0769 -0.12564 / 1), color(--oklab 0.52415 0.07304 -0.1322 / 1), color(--oklab 0.52151 0.06918 -0.13877 / 1), color(--oklab 0.51887 0.06532 -0.14533 / 1), color(--oklab 0.51623 0.06146 -0.15189 / 1), color(--oklab 0.51359 0.05761 -0.15845 / 1), color(--oklab 0.51095 0.05375 -0.16501 / 1), color(--oklab 0.50832 0.04989 -0.17157 / 1), color(--oklab 0.50568 0.04603 -0.17813 / 1), color(--oklab 0.50304 0.04217 -0.18469 / 1), color(--oklab 0.5004 0.03831 -0.19125 / 1), color(--oklab 0.49776 0.03445 -0.19781 / 1), color(--oklab 0.49512 0.03059 -0.20437 / 1), color(--oklab 0.49248 0.02673 -0.21093 / 1), color(--oklab 0.48984 0.02287 -0.21749 / 1), color(--oklab 0.4872 0.01901 -0.22405 / 1), color(--oklab 0.48456 0.01515 -0.23061 / 1), color(--oklab 0.48192 0.01129 -0.23717 / 1), color(--oklab 0.47928 0.00743 -0.24374 / 1), color(--oklab 0.47665 0.00357 -0.2503 / 1), color(--oklab 0.47401 -0.00029 -0.25686 / 1), color(--oklab 0.47137 -0.00415 -0.26342 / 1), color(--oklab 0.46873 -0.00801 -0.26998 / 1), color(--oklab 0.46609 -0.01187 -0.27654 / 1), color(--oklab 0.46345 -0.01573 -0.2831 / 1), color(--oklab 0.46081 -0.01959 -0.28966 / 1), color(--oklab 0.45817 -0.02345 -0.29622 / 1), color(--oklab 0.45553 -0.02731 -0.30278 / 1), color(--oklab 0.45289 -0.03117 -0.30934 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1), color(--oklab 0.45201 -0.03246 -0.31153 / 1)]
>>> i = Color.interpolate(['red', 'blue'], extrapolate=True)
>>> Ramp([i((r * factor / 100) - offset) for r in range(101)])
[color(--oklab 0.67194 0.28919 0.23519 / 1), color(--oklab 0.6693 0.28533 0.22863 / 1), color(--oklab 0.66666 0.28147 0.22207 / 1), color(--oklab 0.66402 0.27761 0.21551 / 1), color(--oklab 0.66138 0.27375 0.20895 / 1), color(--oklab 0.65875 0.26989 0.20239 / 1), color(--oklab 0.65611 0.26603 0.19583 / 1), color(--oklab 0.65347 0.26217 0.18927 / 1), color(--oklab 0.65083 0.25831 0.1827 / 1), color(--oklab 0.64819 0.25445 0.17614 / 1), color(--oklab 0.64555 0.2506 0.16958 / 1), color(--oklab 0.64291 0.24674 0.16302 / 1), color(--oklab 0.64027 0.24288 0.15646 / 1), color(--oklab 0.63763 0.23902 0.1499 / 1), color(--oklab 0.63499 0.23516 0.14334 / 1), color(--oklab 0.63235 0.2313 0.13678 / 1), color(--oklab 0.62971 0.22744 0.13022 / 1), color(--oklab 0.62708 0.22358 0.12366 / 1), color(--oklab 0.62444 0.21972 0.1171 / 1), color(--oklab 0.6218 0.21586 0.11054 / 1), color(--oklab 0.61916 0.212 0.10398 / 1), color(--oklab 0.61652 0.20814 0.09742 / 1), color(--oklab 0.61388 0.20428 0.09086 / 1), color(--oklab 0.61124 0.20042 0.0843 / 1), color(--oklab 0.6086 0.19656 0.07774 / 1), color(--oklab 0.60596 0.1927 0.07117 / 1), color(--oklab 0.60332 0.18884 0.06461 / 1), color(--oklab 0.60068 0.18498 0.05805 / 1), color(--oklab 0.59805 0.18112 0.05149 / 1), color(--oklab 0.59541 0.17726 0.04493 / 1), color(--oklab 0.59277 0.1734 0.03837 / 1), color(--oklab 0.59013 0.16954 0.03181 / 1), color(--oklab 0.58749 0.16568 0.02525 / 1), color(--oklab 0.58485 0.16182 0.01869 / 1), color(--oklab 0.58221 0.15796 0.01213 / 1), color(--oklab 0.57957 0.1541 0.00557 / 1), color(--oklab 0.57693 0.15024 -0.00099 / 1), color(--oklab 0.57429 0.14638 -0.00755 / 1), color(--oklab 0.57165 0.14252 -0.01411 / 1), color(--oklab 0.56901 0.13866 -0.02067 / 1), color(--oklab 0.56638 0.1348 -0.02723 / 1), color(--oklab 0.56374 0.13094 -0.0338 / 1), color(--oklab 0.5611 0.12708 -0.04036 / 1), color(--oklab 0.55846 0.12322 -0.04692 / 1), color(--oklab 0.55582 0.11936 -0.05348 / 1), color(--oklab 0.55318 0.1155 -0.06004 / 1), color(--oklab 0.55054 0.11164 -0.0666 / 1), color(--oklab 0.5479 0.10778 -0.07316 / 1), color(--oklab 0.54526 0.10392 -0.07972 / 1), color(--oklab 0.54262 0.10006 -0.08628 / 1), color(--oklab 0.53998 0.0962 -0.09284 / 1), color(--oklab 0.53735 0.09234 -0.0994 / 1), color(--oklab 0.53471 0.08848 -0.10596 / 1), color(--oklab 0.53207 0.08462 -0.11252 / 1), color(--oklab 0.52943 0.08076 -0.11908 / 1), color(--oklab 0.52679 0.0769 -0.12564 / 1), color(--oklab 0.52415 0.07304 -0.1322 / 1), color(--oklab 0.52151 0.06918 -0.13877 / 1), color(--oklab 0.51887 0.06532 -0.14533 / 1), color(--oklab 0.51623 0.06146 -0.15189 / 1), color(--oklab 0.51359 0.05761 -0.15845 / 1), color(--oklab 0.51095 0.05375 -0.16501 / 1), color(--oklab 0.50832 0.04989 -0.17157 / 1), color(--oklab 0.50568 0.04603 -0.17813 / 1), color(--oklab 0.50304 0.04217 -0.18469 / 1), color(--oklab 0.5004 0.03831 -0.19125 / 1), color(--oklab 0.49776 0.03445 -0.19781 / 1), color(--oklab 0.49512 0.03059 -0.20437 / 1), color(--oklab 0.49248 0.02673 -0.21093 / 1), color(--oklab 0.48984 0.02287 -0.21749 / 1), color(--oklab 0.4872 0.01901 -0.22405 / 1), color(--oklab 0.48456 0.01515 -0.23061 / 1), color(--oklab 0.48192 0.01129 -0.23717 / 1), color(--oklab 0.47928 0.00743 -0.24374 / 1), color(--oklab 0.47665 0.00357 -0.2503 / 1), color(--oklab 0.47401 -0.00029 -0.25686 / 1), color(--oklab 0.47137 -0.00415 -0.26342 / 1), color(--oklab 0.46873 -0.00801 -0.26998 / 1), color(--oklab 0.46609 -0.01187 -0.27654 / 1), color(--oklab 0.46345 -0.01573 -0.2831 / 1), color(--oklab 0.46081 -0.01959 -0.28966 / 1), color(--oklab 0.45817 -0.02345 -0.29622 / 1), color(--oklab 0.45553 -0.02731 -0.30278 / 1), color(--oklab 0.45289 -0.03117 -0.30934 / 1), color(--oklab 0.45025 -0.03503 -0.3159 / 1), color(--oklab 0.44762 -0.03889 -0.32246 / 1), color(--oklab 0.44498 -0.04275 -0.32902 / 1), color(--oklab 0.44234 -0.04661 -0.33558 / 1), color(--oklab 0.4397 -0.05047 -0.34214 / 1), color(--oklab 0.43706 -0.05433 -0.3487 / 1), color(--oklab 0.43442 -0.05819 -0.35527 / 1), color(--oklab 0.43178 -0.06205 -0.36183 / 1), color(--oklab 0.42914 -0.06591 -0.36839 / 1), color(--oklab 0.4265 -0.06977 -0.37495 / 1), color(--oklab 0.42386 -0.07363 -0.38151 / 1), color(--oklab 0.42122 -0.07749 -0.38807 / 1), color(--oklab 0.41858 -0.08135 -0.39463 / 1), color(--oklab 0.41595 -0.08521 -0.40119 / 1), color(--oklab 0.41331 -0.08907 -0.40775 / 1), color(--oklab 0.41067 -0.09293 -0.41431 / 1), color(--oklab 0.40803 -0.09679 -0.42087 / 1)]

Lastly, it is important to note that this affects stops as well, mainly stops applied to interpolation endpoints. When an endpoint is moved inwards via a color stop, the end range of the interpolation is clamped, extending the star and end color. But when extrapolation is enabled, a color stop on an endpoint essentially moves the start and end interpolation. And since there are no other colors on either end to interpolate with, extrapolation occurs.

>>> Color.interpolate([stop('red', 0.25), stop('blue', 0.75)])
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d86010>
>>> Color.interpolate([stop('red', 0.25), stop('blue', 0.75)], extrapolate=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d59510>

Undefined/NaN Handling

Color spaces that have hue coordinates often have rules about when the hue is considered relevant. For instance, in the HSL color space, if saturation is zero, the hue is essentially powerless. This is because the color is "without color" or achromatic; therefore, the hue can have no affect on the actual color.

ColorAide will generally respect the values a user provides, so if an achromatic HSL color is given a hue of 270 degrees, ColorAide will accept it, but the hue will not affect the color in any meaningful way.

During conversions, such context is lost, and if an achromatic color is converted to the color space like HSL, the resultant color will have a hue that is noted as undefined. This is simply because there is no good hue for achromatic colors as they play no part in the color. Any hue is actually incorrect as achromatic colors have no real hue. Instead, colors will be returned with a value that represents that the hue is missing or undefined, or maybe better worded, could not be defined.

Many libraries, like d3-color, chroma.js, and color.js, represent null hues with NaN (not a number). This is usually done to make color interpolation easier. Some, like d3-color, are a bit more liberal with NaN and will target special cases that are above and beyond the normal rules to help ensure good interpolation. For instance, they not only mark hue undefined on HSL colors when saturation is zero, but they'll mark saturation as NaN when lightness indicates "black" or "white".

ColorAide also uses NaN, or in Python float('nan'), to represent undefined channels. In certain situations, when a hue is deemed undefined, the hue value will be set to coloraide.NaN, which is just a constant containing float('nan').

When performing linear interpolation, where only two color's channels are ever being evaluated together at a given time, if one color's channel has a NaN, the other color's channel will be used as the result. If both colors have a NaN for the same channel, then NaN will be returned.

Continuous NaN Handling

NaN handling is a bit different for the Continuous and Cubic Spline interpolation approaches. Linear only evaluates colors at a given time, while the others will take into consideration more than two colors. Because the context is much wider and more complicated, NaN values will often get context from both sides.

Notice that in this example, because white's saturation is zero, the hue is undefined. Because the hue is undefined, when the color is mixed with a second color (green), the hue of the second color is used.

>>> color = Color('white').convert('hsl')
>>> color[:-1]
[nan, 0.0, 1.0]
>>> color2 = Color('green').convert('hsl')
>>> color2[:-1]
[120.0, 1.0, 0.25098039215686274]
>>> color.mix(color2, space="hsl")
color(--hsl 120 0.5 0.62549 / 1)

But if we manually set the hue to 0 instead of NaN, we can see that the mixing goes quite differently.

>>> color = Color('white').convert('hsl').set('hue', 0)
>>> color[:-1]
[0.0, 0.0, 1.0]
>>> color2 = Color('green').convert('hsl')
>>> color2[:-1]
[120.0, 1.0, 0.25098039215686274]
>>> color.mix(color2, space="hsl")
color(--hsl 60 0.5 0.62549 / 1)

Technically, any channel can be set to NaN. And there are various ways to do this. The Color Manipulation documentation goes into the details of how these NaN values naturally occur and the various ways a user and manipulate them.

Carrying-Forward

Experimental

This feature is provided to give parity with CSS behavior. As the spec is still in flux, behavior is subject to change or feature could be removed entirely. Use at your own risk.

CSS introduces the concept of carrying-forward undefined channels of like color spaces during conversion to the interpolating color space. The idea is to provide a sane handling to users who specified undefined channels for interpolation, but did not account for the conversion to the interpolating color space.

If a color has undefined channels, and is converting to a like color space, after conversion the new color will have the same undefined channels, assuming the channels support carrying-forward. The example below demonstrates the concept.

>>> rgb = Color('srgb', [0.5, NaN, 0.8])
>>> p3 = rgb.convert('display-p3').set('green', NaN)
>>> rgb, p3
(color(srgb 0.5 none 0.8 / 1), color(display-p3 0.45659 none 0.76952 / 1))

ColorAide, by default, expects the user to be aware that undefined values are lost if conversion is required for interpolation. This is mainly because the intent of the color can be changed during this process, but some users may find the automatic carrying-forward more convenient. For this reason, ColorAide has implemented carrying-forward as an optional feature via the carryforward option.

In this example, interpolating without carrying-forward results in an interpolation between a purplish color and white. Using carrying-forward, we get a purplish color with an undefined green channel. The green channel takes on the white's green channel giving us an interpolation between a more greenish color and white.

>>> Color.interpolate(['color(srgb 0.5 none 0.8)', 'white'], space='display-p3')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d338d0>
>>> Color.interpolate(['color(srgb 0.5 none 0.8)', 'white'], space='display-p3', carryforward=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d67550>

Depending on the color space, carrying-forward may have better or worse results.

The following table shows channel components supported for carryforward. Spaces may use different names for their channels, but if they are derived from the related space classes, their channels are supported. For instance, xyz is derived from RGBish, so x, y, and z is treated like super saturated r, g, and b.

Space Type Channel Equivalents
RGBish r, g, b
LABish l
LCHish l, c, h
HSLish h, s, l
HSVish h, s, v
Cylindrical h

Carrying-forward is applied within categories.

Category Components
Reds r
Greens g
Blues b
Lightness l
Colorfulness c, s
Hue h
Opponent a a
Opponent b b
Value v

Powerless Hues

Experimental

This feature is provided to give parity with CSS behavior. As the spec is still in flux, behavior is subject to change or feature could be removed entirely. Use at your own risk.

Normally, ColorAide respects the user's explicitly defined hues. This gives the user power to do things like masking off all channels but the hue in order to interpolate only the hue channel.

>>> Color.interpolate(['oklch(none none 0)', 'oklch(0.75 0.2 360)'], space='oklch', hue='specified')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d67510>

But when doing this, a user must explicitly define the hue as achromatic if they want the hue to be ignored. Conversions of achromatic colors to a cylindrical space will, in most cases, have the hue automatically set to undefined.

>>> Color.interpolate(['oklch(1 0 0)', 'oklch(0.75 0.2 180)'], space='oklch')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09ff7f90>
>>> Color.interpolate(['oklch(1 0 None)', 'oklch(0.75 0.2 180)'], space='oklch')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d2d550>

CSS has the concept of powerless hues which causes explicitly defined hues to be powerless (or act as undefined) when a color is considered achromatic. This means a user never has to think about achromatic hues, so even if they erroneously define a hue, the hue will automatically be treated as undefined when interpolating. ColorAide implements this behavior via the powerless option.

>>> Color.interpolate(['oklch(1 0 0)', 'oklch(0.75 0.2 180)'], space='oklch', powerless=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09df4110>
>>> Color.interpolate(['oklch(1 0 None)', 'oklch(0.75 0.2 180)'], space='oklch', powerless=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09e2a910>

The one downside is that control over the hue will be diminished to some degree as ColorAide will no longer respect a user's explicit hue if the color is determined to be achromatic.

>>> Color.interpolate(['oklch(none none 0)', 'oklch(0.75 0.2 360)'], space='oklch', hue='specified')
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09df43d0>
>>> Color.interpolate(['oklch(none none 0)', 'oklch(0.75 0.2 360)'], space='oklch', hue='specified', powerless=True)
<coloraide.interpolate.linear.InterpolatorLinear object at 0x7fbb09d5bc10>