DEV Community

loading...
Cover image for Linear Color Gradients from Scratch

Linear Color Gradients from Scratch

ndesmic
I like to make fun web things from scratch. Ideally build-less, framework-less, infrastructure-less and free from the annoyances of my day job.
Updated on ・14 min read

Almost every visual API has some way of calculating gradients but what about when you want to do it yourself? Maybe you're doing something special, maybe you're building your own graphics API. Let's try to make some by hand.

Note that we'll be using web APIs and JS but should be easily transferable to any other stack.

A simple 2-stop gradient

A Simple Gradient

Let's start with the simple case, a gradient with 2 stops. We want to gradually shift from one color to the other. This process is called linear interpolation or "lerping". The way we can visualize this process is by thinking about colors in terms of physical coordinates. Instead of XYZ, color coordinates are along the RGB axes. What we want is to take 2 point in color space, draw a line between them, and then traverse the all the points on the line.

In this case I've used pure red #FF0000 and pure blue #0000FF. In between we'd expect a purple color.

First let's normalize things to make it easier to calculate. I want the colors to be represented as arrays with values between 0-1. So red (#ff0000) would be [1.0,0,0]. Then I want to take any value along the length of the line with 0 being the very beginning and 1 the very end. We can make this a function:

function lerp(colors, value){
    return [
        colors[0][0] + (colors[1][0] - colors[0][0]) * value,
        colors[0][1] + (colors[1][1] - colors[0][1]) * value,
        colors[0][2] + (colors[1][2] - colors[0][2]) * value
    ];
}

Enter fullscreen mode Exit fullscreen mode

Where colors is an array with both colors. In the example my gradient is 400px wide, so I just need find 400 values between 0 and 1. (1 / 400) * i where i goes from 1 to 400.

What we expect is that at value 0.25 we get color 0.75,0,0.25, at 0.5 we get color 0.5,0,0.5 and at 0.75 we get 0.25,0,0.75 etc. What's happening is we find the distance between the points in terms of each component R,G,B. So along the red axis the difference is 1, and we just multiply by how far along we are plus the starting value. Blue is the reverse, it's the same length but we're moving in the reverse direction and we start at 0 instead of 1. The sign is important to know if we are increasing or decreasing. Green is 0 at both points so it never changes. If this was a less simple color we might have length shorter than 1 for the component but we're still just finding the fraction of the length that the current point represents.

If it easier you can use hex from input an convert it with this function:

function hexColorToFloatColor(hex){
  return [
    parseInt(hex.substring(0,2), 16) / 255,
    parseInt(hex.substring(2,4), 16) / 255,
    parseInt(hex.substring(4,6), 16) / 255
  ];
}
Enter fullscreen mode Exit fullscreen mode

Technically we don't need to normalize it as a float, 0-255 will still work though you might need to do some rounding to the nearest whole value if you plan to directly use the result.

Multi-stop gradient

Gradient with Multiple Stops

Sometimes you want more than one gradient stop so you can seamless transition across multiple values. This isn't too different from before.

function linearGradient(stops, value) {
    const stopLength = 1 / (stops.length - 1);
    const valueRatio = value / stopLength;
    const stopIndex = Math.floor(valueRatio);
    if (stopIndex === (stops.length - 1)) {
        return stops[stops.length - 1];
    }
    const stopFraction = valueRatio % 1;
    return lerp(stops[stopIndex], stops[stopIndex + 1], stopFraction);
}
function lerp(pointA, pointB, normalValue) {
    return [
        pointA[0] + (pointB[0] - pointA[0]) * normalValue,
        pointA[1] + (pointB[1] - pointA[1]) * normalValue,
        pointA[2] + (pointB[2] - pointA[2]) * normalValue,
        pointA[3] + (pointB[3] - pointA[3]) * normalValue,
    ];
}
Enter fullscreen mode Exit fullscreen mode

The difference is we're not just hardcoding 2 values. The value represents how far along we are on the whole color path. So to find the color at the value location we need to first find which two colors we are between. This is indicated by stopIndex which is the value divide by the length between each stop. We need to floor it so we can get the integer index. This will be the first color and stopIndex + 1 is the ending color. What's left over is a new value between the two points. We can get the fractional bit of a float by taking the modulo 1. Then it's the same as the 2-stop problem above, just with the fractional bit instead of the whole value.

lerp has been generalized because it's really just about finding a point between two points based on a ratio, it doesn't have to be colors but for simplicity I've capped the dimensions at 4. It will not change from here on out.

Note that this function is just designed to find a color value between stops. If you want to actually draw the gradient you might instead precompute the stop calculations and iterate over the pixels here rather than call this function for each pixel. The rest of the examples will be presented the same way so feel free to make that optimization.

Alpha

Gradient with Alpha

This is just a simple addition but colors tend to have 4 components with the 4th one being alpha. Since alpha is often times optional, with the omitted value meaning opaque there's a little extra we can add to our function to account for this:

if(stop.length > 4) color.push(1);
Enter fullscreen mode Exit fullscreen mode

Multi-stop with locations

Multi-stop Gradient with Locations

So far we've just look at equally spacing the stops but we can get some interesting effects if we're more flexible than that. A common effect that uses locations is to get discrete color changes by positioning a color start at the same location as a color end.

For this we need a way to represent the stop. To make things simple I'm going to say that color stops are now 5-value tuples between 0 and 1 with the 5th component representing the location.

If you were parsing stops from hex codes here's where you might consider switching formats as 10-value hex codes colors aren't especially common or intuitive. I chose to go with a JSON array of arrays.

eg:

const stops = [[1,1,0,1,0],[1,0,0,1,0.33]];
Enter fullscreen mode Exit fullscreen mode

Cleanup

First I want to clean some stuff up. As we saw with adding alpha things are starting to get messy because we're using a bunch of data types with non-obvious requirements. To help with this I'd like to split the linearGradient function up to deal with validation and then the actual lerp algorithm. This will make it easier to deal with.

We have several requirements for the data structure we take in. Often this is where we would leverage a type system to ensure bad data can't get in but there are few languages with a type system powerful enough to represent what we need.

1) The stop array must have at least 2 elements (otherwise it's just a solid color)
2) Each color must be length 5
3) Each element of of each color must be between 0 and 1
4) The first stop must be at location 0
5) The last stop must be at location 1
6) Stop locations must be in ascending order

If these are not true then we have bad data and should error. However, it makes sense that we don't need the user to enter all of this data, we can make some reasonable decisions when it's slightly incomplete. The two assumptions I think that are safe to make are:

1) If a color does not have an alpha, we can assume it's 1
2) If all colors lack locations, we can spread them evenly

Note this means having just 3 or 4 elements per color like the beginning examples is still valid. If any stop has a location then you need to manually place them as there's no sensible way to figure out how they were supposed to go once one has been placed. Technically we could deal if just the two ends were omitted or just the two ends were present but I'm not sure if that's a good idea. We want users to be consistent.

Here's what I came up with to validate/fix the inputs:

function validateStops(stops) {
    if (stops.length < 2) throw "Gradient requires at least 2 colors";
    for (const color of stops) {
        if (color.length === 3) color.push(1);
        for (const component of color) {
            if (component < 0 || component > 1) throw `Color stop has out of range component: ${component}`;
        }
    }
    if (stops.every(color => color.length === 4)) {
        const stopLength = 1 / (stops.length - 1);
        stops.forEach((color, i) => color.push(i * stopLength))
    }
    if (stops.some(color => color.length === 4)) {
        throw "Colors must either all have positions, or none have positions";
    }
    if (stops[0][4] !== 0) {
        throw "First color must start at position 0";
    }
    if (stops[stops.length - 1][4] !== 1) {
        throw "Last color must end at position 1";
    }
    let max = 0;
    for (const color of stops) {
        if (color[4] >= max) {
            max = color[4];
        } else {
            throw "Color stops are out of order";
        }
    }
    return stops;
}
Enter fullscreen mode Exit fullscreen mode

If you're really a purist you could make it immutable, I didn't bother.


Back to the task at hand. We now can guarantee that we have correct data coming in which makes this a lot easier. What we want is to simply iterate through the stops and see if the value falls between. If so, then we take the fractional portion (subtract the location of the first matched stop and then divide by the stop length) and lerp it the same way we've been doing.

function linearGradient(stops, value) {
    let stopIndex = 0;
    while(stops[stopIndex + 1][4] < value){
        stopIndex++;    
    }

    const remainder = value - stops[stopIndex][4];
    const stopFraction = remainder / (stops[stopIndex + 1][4] - stops[stopIndex][4]);

    return lerp(stops[stopIndex], stops[stopIndex + 1], stopFraction);
}

Enter fullscreen mode Exit fullscreen mode

I call validateStops outside the function before passing the colors in so it's not present, you can choose to do it on line 1 if you'd like too. Really astute readers who've been through a few whiteboard gauntlets might notice that since the color array is sorted we could do binary search. Since I'm assuming the number of colors is going to be low (almost always less than 10) it's not needed and might actually have more overhead than it's worth, but if you expect a million stops you can update that.

Draw Loop

While I've shown how to get a color between stops it might be helpful to setup a draw loop as some of the other types of drawing transforms will happen outside of the gradient function itself.

The one I use looks like this:

renderGradient(){
    const context = this.dom.canvas.getContext("2d");
    const imageData = context.getImageData(0,0,this.dom.canvas.width, this.dom.canvas.height);
    for(let i =0; i < imageData.width; i++){
        for(let j = 0; j < imageData.height; j++){imageData.width / 2, imageData.height / 2);
            const color = linearGradient(this.#stops, (1.0 / imageData.width) * i);
            imageData.data[(j * imageData.width * 4) + 4 * i] = color[0] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 1] = color[1] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 2] = color[2] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 3] = color[3] * 255;
        }
    }
    context.putImageData(imageData, 0, 0);
}
Enter fullscreen mode Exit fullscreen mode

It's writing to pixelData of an HTML canvas. This is from a web component that uses my normal boilerplate code so this.dom.canvas is a reference to a canvas associated with the class.

Only i is important because by default the gradients are considered horizontal.

Rotation

download (4)

Sometime we want to have that gradient at an angle. In order to do that we need to stick a transform in where we get the coordinate:

renderGradient(){
    const context = this.dom.canvas.getContext("2d");
    const imageData = context.getImageData(0,0,this.dom.canvas.width, this.dom.canvas.height);
    const mx = imageData.width / 2;
    const my = imageData.height / 2;
    for(let i =0; i < imageData.width; i++){
        for(let j = 0; j < imageData.height; j++){
            const [x,y] = translate(rotate(translate([i, j], -mx, -my), this.#angle), mx, my);
            const color = linearGradient(this.#stops, (1.0 / imageData.width) * x);
            imageData.data[(j * imageData.width * 4) + 4 * i] = color[0] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 1] = color[1] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 2] = color[2] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 3] = color[3] * 255;
        }
    }
    context.putImageData(imageData, 0, 0);
}
Enter fullscreen mode Exit fullscreen mode

this.#angle is a property that specifies the angle.

function rotate(position, angle){
    return [
        Math.cos(angle) * position[0] - Math.sin(angle) * position[1],
        Math.sin(angle) * position[0] + Math.cos(angle) * position[1]
    ];
}
function translate(position, x, y){
    return [
        position[0] + x,
        position[1] + y
    ]
}
Enter fullscreen mode Exit fullscreen mode

The translate and rotate functions are pretty simple and should be familiar if you've ever translated or rotated something before. These are pretty stock functions you'll find everywhere. You can use the matrix forms if you are more familiar with that but I'm trying not to introduce too much.

What might seems a little weird is that we translate by -mx and -my and then translate back. The purpose is to get the coordinates in a more natural system otherwise we have to add extra terms to the rotate function to serve as the transform origin. The transform origin is mx, my (middle x and middle y) so if we translate in reverse then our origin is at now at 0,0 and the rotation works properly. Then we need to restore it to the original coordinates for drawing. Note that if you want to go further to normalize you can flip the y-axis and and scale everything to be between 0 and 1. That doesn't help us much here so I didn't but this is common in computer graphics.

If we just do this though we'll get an error because we're trying to find a point that is out of bounds. A line cutting through the corners of a rectangle is longer than the width and so using our same calculations for the value we can get values less than 0 and more than 1 so we need to deal with these now. I've updated the linearGradient function:

function linearGradient(stops, value) {
    if(value > 1){ 
        return stops[stops.length - 1].slice(0, 4);
    }
    if(value < 0) {
        return stops[0].slice(0,4);
    }
    let stopIndex = 0;
    while(stops[stopIndex + 1][4] < value){
        stopIndex++;    
    }

    const remainder = value - stops[stopIndex][4];
    const stopFraction = remainder / (stops[stopIndex + 1][4] - stops[stopIndex][4]);

    return lerp(stops[stopIndex], stops[stopIndex + 1], stopFraction);
}
Enter fullscreen mode Exit fullscreen mode

When we get those value, we'll just clamp it to the end colors and this produces a nice continuous effect.

Translation

download (1)

For a little more finer control we can also shift the gradient. This isn't too hard to do, we simply take the displacement from the center (our default starting point) and add that to the coordinates. Note that translation is merely an API nice-to-have, we can manually construct any gradient that could be produced via translation with what we have already.

renderGradient(){
    const context = this.dom.canvas.getContext("2d");
    const imageData = context.getImageData(0,0,this.dom.canvas.width, this.dom.canvas.height);
    const mx = imageData.width / 2;
    const my = imageData.height / 2;
    const cx = this.#cx ?? mx;
    const cy = this.#cy ?? my;
    const cxOffset = cx - mx;
    const cyOffset = cy - my;
    for(let i =0; i < imageData.width; i++){
        for(let j = 0; j < imageData.height; j++){
            const [x, y] = rotate(i + cxOffset, j + cyOffset, this.#angle, mx, my);
            const color = linearGradient(this.#stops, (1.0 / imageData.width) * x);
            imageData.data[(j * imageData.width * 4) + 4 * i] = color[0] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 1] = color[1] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 2] = color[2] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 3] = color[3] * 255;
        }
    }
    context.putImageData(imageData, 0, 0);
}
Enter fullscreen mode Exit fullscreen mode

In the component this.#cx and this.#cx are absolute center x and center y. If not given they default to the center of the canvas. cxOffset and cyOffset are the displacement from mx and my the absolute default middle x and middle y (if you can think of better names please use them). When we get x and y we just add another step to translate:

const [x, y] = translate(translate(rotate(translate([i, j], -mx, -my), this.#angle), cxOffset, cyOffset), mx, my);
Enter fullscreen mode Exit fullscreen mode

Keep in mind the order is important. If we translate before rotating that will give a different result than if we translate after rotating because the rotation origin will change. Neither is more correct, it's up to you to figure out how you want the API to work.

Scaling

download

We have translation and rotation, might as well add scaling too. The scale function is straightforward:

function scale(position, sx = 1, sy = 1){
    return [
        position[0] * sx,
        position[1] * sy
    ];
}
Enter fullscreen mode Exit fullscreen mode

Again we just insert this into the transformation chain:

//const length = this.#length ?? imageData.width;

const [x, y] = translate(translate(rotate(scale(translate([i, j], -mx, -my), imageData.width/length, 1), this.#angle), cxOffset, cyOffset), mx, my);
Enter fullscreen mode Exit fullscreen mode

Here we introduce length which if not defined is just the width (we're still assuming all gradients are horizontal). The scale is the ratio between the explicit length and the actual width. Since the gradients are 1-dimensional the y-scale doesn't matter and is fixed at 1 (if you scale after rotation you'll need to consider it). Note that it might be counter intuitive that a scale greater than 1 makes the gradient smaller. This is because the scale is really the scale of the image we're sampling from. If I copy from a big canvas to one that is smaller the details get smaller. This is also true for rotation and translation, we're not translating the output we're translating the input.

And again I'll remind you that the order of operations matters.

Square Gradients

2d Gradient

We can even make gradients 2d. There are some common types that you might have seen like radian and conic gradients and I might cover those in a another post, but I wanted to end on a 2d linear gradient, which can be used to make things things like color pickers.

What we do is simply expand our linear gradient function to take an array of stops. The stop data starts getting a little confusing here so let's examine it in detail.

[  //stop dimensions
 [ //stop list for a single dimension
   [1,0,0,1,0], //stop (R, G, B, A, L)
   [0,0,1,1,1]  //stop (R, G, B, A, L)
 ],
 [ //stop list for a single dimension
   [0,0,0,1,0], //stop (R, G, B, A, L)
   [1,1,1,1,1]  //stop (R, G, B, A, L)
 ]
]
Enter fullscreen mode Exit fullscreen mode

Let's update the validation. In fact, we don't really need to since it still works but now we're dealing with a higher plane so we need a new higher-level function, validateStopDimensions:

function validateStopDimensions(stopDimensions){
    for(const stops of stopDimensions){
        validateStops(stops);
    }
    return stopDimensions;
}
Enter fullscreen mode Exit fullscreen mode

And we'll call this where previously we were calling validateStops.

And here's the new gradient function:

function linearGradient(stopDimensions, values) {

    let currentStop = null;  

    for(let i = 0; i < values.length; i++){
        let stopIndex = 0;
        while (stopDimensions[i][stopIndex + 1][4] < values[i]) {
            stopIndex++;
        }

        const remainder = values[i] - stopDimensions[i][stopIndex][4];
        const stopFraction = remainder / (stopDimensions[i][stopIndex + 1][4] - stopDimensions[i][stopIndex][4]);

        if(!currentStop){
            currentStop = lerp(stopDimensions[i][stopIndex], stopDimensions[i][stopIndex + 1], stopFraction);
        } else {
            currentStop = add(currentStop, lerp(stopDimensions[i][stopIndex], stopDimensions[i][stopIndex + 1], stopFraction));
        }
    }

    return currentStop;
}
Enter fullscreen mode Exit fullscreen mode

Some things were renamed for clarity but what's new is that we take in stop dimensions and an array of values and loop over them. The inner part is the same as before except we aggregate the results (this could have been a reduce function but I think this might be a little easier to understand). For each dimension we calculate the color, and then we do something with the colors between the dimensions to squash it back down into a single color. This something can be a lot of things that go a bit beyond the scope of this post called "blend modes". They all produce different effects but a simple one I chose to use for the example is add. Add is exactly as it sounds:

function add(a,b){
    return [
        a[0] + b[0],
        a[1] + b[1],
        a[2] + b[2],
        a[3] + b[3],
    ];
}
Enter fullscreen mode Exit fullscreen mode

Just add the components together.

Another way to look at it if that didn't make sense is that you really have 2 1d linear gradients (x and y) and you're simply blending the results together.

By the way, the function should work for n-dimension gradients. I have no idea how to make an example of even a 3d gradient (I guess a 3D shape in a color-field of some sort?). You could also scale and rotate these gradients too but it's the exact same process for linear gradients so I think we can leave it there.

There's definitely a lot more to this topic but for now I hope this helps your understanding of linear gradients and computer graphics!

If you want code to play around with I have a linear gradient web-component here: https://github.com/ndesmic/wc-lib/blob/master/gradient/wc-linear-gradient.js and a square gradient web-component here: https://github.com/ndesmic/wc-lib/blob/master/gradient/wc-square-gradient.js

Discussion (0)