Almost every creative coding project involves sampling colours. Depending on how much creative control we want, we can narrow down the pool of colours to sample from. Some projects call for a handful of hand-picked colours, while others benefit from utilising the entire spectrum.
In this post, we’ll look at various techniques to pick colours for creative coding. We’ll start simple and gradually build up to more advanced approaches.
But before we dive into free-form colour sampling, let’s consider colour for data visualisation.
A note on data-colour accuracy
For projects where colour conveys information, like in data visualisation, we want them to be accurate, legible, and accessible. Notice how yellow and blue do not appear equally bright. If we were to visualise one data point yellow, and the other blue, the difference in brightness may be interpreted as a difference in weight or priority. Human perception is a messy topic, so we have to be mindful about how data is presented through colour.
We can correct for inaccuracies, both perceived and presented, but this is a science that is far beyond the scope of this introductory article. To learn more about this topic, I recommend the following resources:
- A Better Default Colormap for Matplotlib: a talk by Nathaniel Smith and Stéfan van der Walt on the design of “viridis”, a colour map for sequential data.
- Choosing Colormaps in Matplotlib: a guide to select a colour map for different classes of data visualisations with great defaults and a bunch of tests. While it’s a Matplotlib-tailored guide, it contains many takeaways for all.
Representations
Before we get to sampling colours, let’s consider different colour representations. We perceive, and our screens generally emit three colours: red, green, and blue. It’s no coincidence that we typically define colours in quantities of red, green, and blue as well. In CSS, that’s often written in hexadecimal (e.g. #FF0000 ) or with the RGB function notation (e.g. rgb(255 0 0) ).
If we were to sample colours from the RGB function notation, we have three individual components to influence and compute colours from. We can visualise all possible combinations in a cube where the X, Y, and Z coordinates map to red, green, and blue, respectively. In the demo below, I’ve reduced the number of colours to keep your browser happy.
This representation has some usability quirks. Imagine we want to sample colours that make a black-blue-white gradient. We can plot this, creating a line that runs through our RGB cube. It’d start in the corner with the black sphere and travel towards the corner with the solid blue sphere. This is equivalent to adding blue (from rgb(0 0 0) to rgb(0 0 255) , in our RGB function notation). From there, to move to white, we’d have to change direction and diagonally cross the blue-magenta-white-cyan face. This is equivalent to adding equal amounts of red and green until we reach rgb(255 255 255) . This change in direction is something we’d have to include in our sampling algorithm with an if-statement or by clamping:
// When `t` is 0-0.5, `r` increases from 0 to 1. When `t` exceeds 0.5, `r` stays at 1.
const r = 2 * Math.min(0.5, t);
// When `t` is 0-0.5, `gb` is 0. When `t` is 0.5-1, `gb` increases from 0 to 1.
const gb = 2 * (Math.max(0.5, t) - 0.5);
const color = `rgb(${255 * r} ${255 * gb} ${255 * gb})`;
This complexity isn’t necessary when using other representations. A more intuitive alternative is the hue, saturation, lightness (HSL) model. Hue determines what colour we want, as if it selects a colour on a rainbow. Saturation determines how intense a colour is; the least intense value makes grey. Finally, lightness ranges from white to black with a colour in between. Our relatively simple gradient from before can thus be achieved by only adjusting the lightness component. We start at hsl(240 100% 0) , implicitly have hsl(240 100% 50%) halfway, and end at hsl(240 100% 100%) . Note how the code below is much simpler than the code we needed to compute the same colours with the rgb() colour notation:
const color = `hsl(240 100% ${100 * t}%)`;
Contrary to RGB, HSL uses a cylindrical coordinate system, meaning that the hue component wraps around. A value of 360 is the same as a value of 0. We can plot this in a cylinder, like so:
HSL aligns better with our intuition and how we want to sample colours, so it’s typically the go-to colour representation in creative coding.
But there’s more than RGB and HSL. These colour notations are both used to define colours in the sRGB colour space, one of the standards that describes the breadth of what devices (e.g. monitors and printers) can physically produce. Many modern monitors, however, can present a broader set of colours. For example, the CIELAB colour space is designed to reflect what humans can perceive, which is more than what’s available to us in the sRGB colour space. The oklch() notation, for example, leverages the CIELAB colour space. oklch() also achieves (near) perceptual uniformity. Remember that example I gave before, where yellow is perceived as lighter than blue? oklch() corrects for that perceived difference, so if we start with the blue-ish colour from before oklch(0.45 0.3 264) , and only adjust the hue, an equally bright (and thus rather dark) yellow is produced: oklch(0.45 0.3 109) .
Be sure to familiarise yourself with what colour spaces and representations are available in the tools you use, and pick whichever works best for your project!
For the rest of this article, I will mostly stick to the HSL and RGB CSS function notation out of brevity, clarity, and reader familiarity. I also don’t need perceptual uniformity. The ideas and execution should translate to other colour spaces, tools, and languages.
Sample from a continuous range
The most straightforward method of computing colour is by plugging a variable into a colour definition. This works by mapping a numeric value to a colour component.
In the example below, we have a cloth or landscape-like grid of spheres with a gradually varying Y-coordinate. To create dark valleys (hsl(21 100% 0%) ), colourful transitions (hsl(21 100% 50%) ), and bright peaks (hsl(21 100% 100%) ), I can plug each sphere’s Y position into the lightness of the hsl() notation.
const color = `hsl(21 100% ${100 * y}%)`;
In many cases, we have to adjust the range of our value to conform to the expected range in the colour notation. In the example above, we’ve done so by multiplying y by 100, implying that y has a range of 0 to 1. Let’s say y is a value of 1 to 4. How do we map the value to something we can use for our colour notation? To keep things simple, I typically normalise a value to a 0-1 range before I map it to the desired range:
// Assuming `y` is (1, 4)
// `y` - 1 turns the range into (0, 3)
// (...) / 3 turns that range into (0, 1)
const t = (y - 1) / 3;
// To turn (0, 1) to (0, 100), we multiply by 100
const lightness = 100 * t;
The intuitive colour notations like hsl() and oklch() offer meaningful slots to plug values into, but it only works when we can assign a value to one of these individual components. We don’t always want a rainbow; sometimes we want more creative control so that our work aligns with branding, for example. In these cases, we want to sample colours from a predefined set; from a palette.
Sample from a palette
The simplest implementation of sampling from a palette is a uniform, or unweighted, selection of discrete values. In other words, we pick a random item from an array of colours.
const palette = ['#FF0000', '#00FF00', '#0000FF'];
const t = Math.random();
const index = Math.floor(t * palette.length);
const color = palette.at(index);
When we want a colour from a palette to be more common than another, we can add a weight to each colour to describe the ratio between colours. Comparatively, this is more complex.
One way to do this is to compute the cumulative distribution function, or CDF. For our purpose, it’s a helper array that describes the probability range for each item. If we then generate a random number, we can test each probability range to find an index, and get the palette colour based on that index.
type ColorWithWeight = {
color: string;
weight: number;
};
const palette: ColorWithWeight[] = [
{ color: '#FF0000', weight: 2 },
{ color: '#00FF00', weight: 1 },
{ color: '#0000FF', weight: 9 },
];
// Compute the cumulative distribution function
// Note we're not normalising the weights to a (0-1) probability.
// Instead, we multiply our random number by the sum of weights, which has the same effect.
const cumulativeWeights = palette.reduce((result, { color, weight }) => {
if (result.length === 0) {
return [weight];
}
const previousWeight = result.at(-1);
result.push(previousWeight + weight);
return result;
}, []);
const getRandomColor = () => {
// The last item in cumulativeWeights represents the sum of all weights
const sum = cumulativeWeights.at(-1);
// We're multiplying our 'probability' here so we don't have to normalise each weight
const randomValue = Math.random() * sum;
const sampleIndex = cumulativeWeights.findIndex((item) => randomValue < item);
return palette.at(sampleIndex).color;
};
If we assign colours to our grid of spheres based on the above code, we get approximately 2/12th red spheres, 1/12th green spheres, and 9/12th blue spheres.
Now that we’ve learned how to work with palettes, the next step to working with gradients only requires interpolating between colours.
Sample from a gradient
We can reuse much of our knowledge about sampling from palettes to sample from custom gradients, allowing us to pick interpolated colours between handpicked colours.
For the sake of simplicity, we’re only going to consider gradients with equally distributed colours.
In the example below, the Y-axis of the spheres in our scene correspond to a value of a custom blue-red-blue gradient. Note that in the code below, we’ve defined our gradient as a TypeScript tuple instead of using a CSS colour notation. This is to simplify calculating in-between values.
type RgbColor = [r: number, g: number, b: number];
type Gradient = RgbColor[];
const gradient: Gradient = [
[0, 0, 1], // #0000FF
[1, 0, 0], // #FF0000
[0, 0, 1], // #0000FF
];
const color = getColorFromGradient(gradient, y);
Now we need to write getColorFromGradient(), which accepts our gradient, as well as a numeric value with a 0-1 range that points to the position in our gradient we want to compute the colour for.
Let’s start by writing this function’s outline and edge cases:
function getColorFromGradient(gradient: Gradient, value: number): RgbColor {
if (gradient.length === 0) {
return [0, 0, 0];
}
if (gradient.length === 1) {
return gradient.at(0);
}
if (value <= 0) {
return gradient.at(0);
}
if (value >= 1) {
return gradient.at(-1);
}
// …
}
Next, need to get the gradient colours before and after value. We can do that by first calculating the indices that come before and after value. Multiplying value by the gradient size (minus one, because we’re working with indices) is extremely likely to return a fraction between two indices, like 1.333. By rounding that number down and up, we get the indices of 1 and 2, respectively.
We can then reuse that number to figure out where we are between these indices. Is our pointer pointing more towards the colour at index 1, or more towards at index 2? We can figure that out by subtracting our calculated index (1.333) with the floored index (1), giving us 0.333, meaning we want the colour at 1/3rd between index 1 and 2. Let’s call that t.
const index = value * (gradient.length - 1);
const indexBefore = Math.floor(index);
const indexAfter = Math.ceil(index);
const t = index - indexBefore;
The last step is to get the colours at indexBefore and indexAfter, and calculate the in-between value at t. We can do that through linear interpolation, also known as lerping or mixing. Let’s wrap up the getColorFromGradient() function first and define a lerp() function later.
const colorBefore = gradient.at(indexBefore);
const colorAfter = gradient.at(indexAfter);
const color: RgbColor = [
lerp(colorBefore.at(0), colorAfter.at(0), t),
lerp(colorBefore.at(1), colorAfter.at(1), t),
lerp(colorBefore.at(2), colorAfter.at(2), t),
];
The lerp() function takes two numbers, a start and end, and returns an in-between value at t. If t is 0, we return start. When t is 1, we return end. When t is 0.5, we return the value that sits exactly in between start and end. We start by calculating the distance between start and end by subtracting them: end - start. We then multiply that by t. When the distance is multiplied by t=0, we get 0. For t=1, we get the full distance. For t=0.5, we get half the distance. Finally, we add start back to that result to offset that multiplied distance.
function lerp(start: number, end: number, t: number) {
const range = end - start;
return start + t * range;
}
Putting it all together, this is our code:
type RgbColor = [r: number, g: number, b: number];
type Gradient = RgbColor[];
const gradient: Gradient = [
[0, 0, 1], // #0000FF
[1, 0, 0], // #FF0000
[0, 0, 1], // #0000FF
];
function getColorFromGradient(gradient: Gradient, value: number): RgbColor {
if (gradient.length === 0) {
return [0, 0, 0];
}
if (gradient.length === 1) {
return gradient.at(0);
}
if (value <= 0) {
return gradient.at(0);
}
if (value >= 1) {
return gradient.at(-1);
}
const index = value * (gradient.length - 1);
const indexBefore = Math.floor(index);
const indexAfter = Math.ceil(index);
const t = index - indexBefore;
const colorBefore = gradient.at(indexBefore);
const colorAfter = gradient.at(indexAfter);
const color: RgbColor = [
lerp(colorBefore.at(0), colorAfter.at(0), t),
lerp(colorBefore.at(1), colorAfter.at(1), t),
lerp(colorBefore.at(2), colorAfter.at(2), t),
];
return color;
}
function lerp(start: number, end: number, t: number) {
const range = end - start;
return start + t * range;
}
Next steps would be gradients where colour stops aren’t equally distributed and non-linear interpolation. Interestingly, we can apply much of what we learned about weighted palettes to calculate colours for gradients with an uneven colour distribution. Out of brevity, we’re going to move on to another incredibly useful technique: making things cycle!
Periodic sampling
For animations and patterns, we typically want colours to transition smoothly from one to another and start and end at the same colour: we want them to cycle, or to be periodic. In this section, we’re specifically going to turn an indefinitely increasing value time into a periodic function.
We’ve already learned about the cylindrical representation of colours. Since hue in HSL is inherently cyclic, it’s a very quick and easy way to get seamless animations. In the example below, the sphere continuously cycles through hues, transitioning through the entire rainbow over time.
While not strictly necessary, I do want to ensure that the hue we put into the hsl() notation does not exceed 360. We can do that using the modulo operator, which sort of ‘wraps’ the number when it exceeds the second operand, the divisor. So 9 % 10 returns 9, 10 % 10 returns 0, 11 % 10 returns 1, and so on. So time % 360 returns a value between 0 and 359, which is perfect!
const color = `hsl(${time % 360} 100% 50%)`;
Because hue is the only cyclic component, wrapping any of the other components would result in a noticeable jump. Let’s say we animate brightness by increasing it a little over time. When it reaches white, it wraps back to black. Quite jarring, indeed. Instead, we can avoid jumps by making it ping-pong: once our animation reaches white, it starts to darken back to black. Once it reaches black, it starts to lighten to white.
We can ping-pong numbers with some number bending. First, we wrap time so we have a fixed range to work with. time % 2 gives us a 0-2 range. If we then subtract 1, we get a -1 to 1 range. As our time value grows, 1 wraps around to -1. Try to imagine this with steps of 0.5: 0, 0.5, 1 wraps to -1, -0.5, 0, etc. Note how negative numbers move towards 0 with each increment, whereas positive numbers move away from 0. Also note that the list of numbers I just described is symmetrical if we ignore the negative symbol. If we wrap everything with Math.abs(), which turns negative numbers positive, our sequence of numbers becomes: 0, 0.5, 1, 0.5, 0, 0.5, 1, etc. It moves back and forth!
const lightness = Math.abs((time % 2) - 1);
In this particular instance, the abrupt switch from increasing to decreasing lightness isn’t jarring in the resulting gradient. Sometimes, we want a slightly smoother ping-pong, in which case I tend to use a sin() or cos() wave. These output a range of -1 to 1, so we have to offset it by adding 1 to get a 0-2 range, and then squish it to 0-1 by dividing it by 2.
const lightness = (Math.sin(time) + 1) / 2;
Note that the sphere movement is quite smooth compared to the ping-pong technique. Also, take note that by smoothing the peaks and valleys, the animation spends more time at the extremes and less at the bright colours. The effect is subtle, but the black and white bands in the gradient are broader now.
Let’s wrap up with one last technique, one that you have already seen in the previous visualisations.
Geometric sampling
Let’s consider geometry. In this article, we’ve visualised all colour spaces in 3D. All coordinates inside these shapes represent a distinct colour. We have also rendered paths over these shapes and have seen their corresponding gradients.
Consider the gradient below:
type RgbColor = [r: number, g: number, b: number];
type Gradient = RgbColor[];
const gradient: Gradient = [
[0.5, 1, 1], // #80FFFF
[0.5, 1, 0], // #80FF00
[0.5, 0, 0], // #800000
[0.5, 0, 1], // #8000FF
];
Plotting this gradient creates a path that spells out a primitive letter C, for colour!
If gradients can be turned into paths, paths can turn into gradients.
At the beginning of this article, I explained that the RGB colour notation has no cyclic component, unlike HSL. That is still true, but we can make a cyclic motion in a 3D space, like a circle. If we plot a circle within the RGB cube, the colours along that path create a cyclic gradient as seen below.
With the knowledge that gradients and paths within a colour shape are virtually the same thing, we can apply our understanding of three-dimensional space to create gradients. How are predefined gradients affected when we translate, rotate, scale, or deform their paths? What do gradients based on a chaotic system, like a double pendulum or physics simulation, look like?
I’ll leave that for you to explore.
Conclusion
Colour sampling is a fundamental part of creative coding. In this article, we started with simple value mapping, moved on to palettes and gradients, and explored how to make colours change smoothly over time. Along the way, we saw how different colour spaces and representations affect how we work with colour and how different techniques can give authors more or less control over which colours are calculated.
From here, the next step is to experiment. Try combining these approaches, swap out colour spaces, or drive your colours with different inputs.