From our sponsor: Ready to show your plugin skills? Enter the Penpot Plugins Contest (Nov 15-Dec 15) to win cash prizes!
Color is powerful — it can radically shift our mood, inspire us, and help us express ourselves in a way that few other things can. It is a fundamental building block of design, but it can also be a little intimidating.
Often when given the opportunity to play with color, we freeze. Choosing just one can be enough to trigger a kind of iridescent nightmare, not to mention combining lots of them! The options are infinite, and the “rules” somewhat hazy… a potentially overwhelming combination, particularly for those of us used to the (often) more definite world of code.
In this tutorial, we will be learning how to use familiar tools — a text editor and web browser — to make the process of creating striking color palettes a lot less scary and (most importantly!) fun.
Let’s do it!
Intended audience
This article is perfect for folks who already have a good grasp of HTML, CSS (knowledge of HSL and RGB colors will be helpful), and JavaScript. If you love to make things on the web, but often reach for a pre-curated selection or an automatic “generator” when adding color, you’re in the right spot.
Tutorial format
We won’t be building one single, strictly defined project here. Instead, we will be learning to create three special JavaScript functions, all uniquely suited to generating beautiful color palettes. Once written, these functions will form a solid foundation for our very own suite of programmatic color tools, which can be carried from project to project and iterated on/personalized over time.
A short introduction to LCH color
Throughout this tutorial, we will be working almost exclusively with LCH colors. LCH stands for lightness (how dark/light a color is), chroma (how vivid/saturated a color is), and hue (whether a color is red, green, blue…).
In short, LCH is a way of representing color just like RGB or HSL, but with a few notable advantages — the most important for this tutorial being its perceptual uniformity. I know this sounds a little scary, but I promise it’s not; let me show you what it means!
To start, take a look at these two pairs of HSL colors:
Notice how, despite both the top and bottom pairs having the same 20-degree hue variance, the difference we actually see is wildly different? This imbalance exists because HSL is not perceptually uniform.
Now take a look at the same experiment, this time using LCH colors:
👋 — hue values do not align perfectly between HSL and LCH. In LCH, a hue of 0 is more pink, while in HSL, it is a pure red.
Ah, much better! The change in hue seen here is far more balanced because LCH is perceptually uniform.
Next, let’s take a peek at another two HSL colors:
These two colors have identical lightness values but appear very different to our human eyes. The yellow on the left is far “brighter” than the blue on the right.
Here’s a similar setup, but with LCH colors rather than HSL:
That’s more like it! As demonstrated by the image above, lightness values in LCH are far more accurate representations of what we perceive — this, in combination with LCH’s uniform hue distribution, will make our lives a lot easier when creating harmonious color palettes.
For now, this is all we need to know, but if you would like to learn more, I highly recommend this article by Lea Verou.
👋 — We will be using a library in this tutorial, but native LCH support is heading to the browser! In fact, it is already in Safari, with other browsers currently working on it.
Following along
Before we write any code, we need a simple development environment. This setup is entirely your choice, but I recommend spinning up a CodePen to follow along with the examples, then moving to a custom setup/repository as and when you need to. Really, all we need here is an HTML/JavaScript file, and we will be using Skypack for all library imports, so there’s no need for any fancy build processes, etc.
Function #1 — “Scientific”
OK! First off, we are generating colors using “traditional” color theory. To get started with this method, let’s take a look at something called a color wheel:
Look familiar?
A color wheel is a visual representation of the hues in a color space. The wheel above represents the hues in LCH, incrementing in 30-degree steps, from 0 to 360-degrees — a well-established format. In fact, for hundreds of years, we have used wheels to find colors that work well together!
Here’s how:
We start with a base color. Then, we rotate around the wheel by a certain number of degrees a certain number of times; for a perfect complementary palette, we move 180 degrees once:
Lovely! For a triadic palette, we move 120 degrees, twice:
See where this is going? By altering the number of steps and rotation amount, we can create several “classic” color palettes:
Cool! Let’s take this method and turn it into 1s and 0s.
To keep things moving throughout this tutorial, I’ll show you the code, then break it down step-by-step:
The code
function adjustHue(val) {
if (val < 0) val += Math.ceil(-val / 360) * 360;
return val % 360;
}
function createScientificPalettes(baseColor) {
const targetHueSteps = {
analogous: [0, 30, 60],
triadic: [0, 120, 240],
tetradic: [0, 90, 180, 270],
complementary: [0, 180],
splitComplementary: [0, 150, 210]
};
const palettes = {};
for (const type of Object.keys(targetHueSteps)) {
palettes[type] = targetHueSteps[type].map((step) => ({
l: baseColor.l,
c: baseColor.c,
h: adjustHue(baseColor.h + step),
mode: "lch"
}));
}
return palettes;
}
To break this down:
- Define a function
createScientificPalettes
that expects a singlebaseColor
argument. - Define the hue steps for several “classic” color palettes.
- For each palette type: iterate over each hue step, add the step value to the base hue, and store the resulting color — making sure its
chroma
andlightness
values match the base. Use a smalladjustHue
function to ensure all hue values are between 0 and 360. - Return the palettes in LCH format.
Usage
Awesome! We can call our createScientificPalettes
function like so:
const baseColor = {
l: 50,
c: 100,
h: 0,
mode: "lch"
};
const palettes = createScientificPalettes(baseColor);
In the example above, we pass a baseColor
object, and the function returns a variety of palettes, all centered around that base. Thanks to LCH, the lightness and intensity of the colors in these palettes will be visually consistent, and the hue modulations highly accurate; this is great for accessibility, as, unlike other color spaces, each color in the palette will have the same perceived contrast.
Cool! All that’s left to do now is convert the LCH colors to a more usable format. To do so, we can use Culori — an excellent color utility library used throughout this tutorial — to transform the LCH objects to, say, HEX:
import { formatHex } from "https://cdn.skypack.dev/culori@2.0.0";
const baseColor = {
l: 50,
c: 100,
h: 0,
mode: "lch"
};
const palettes = createScientificPalettes(baseColor);
const triadicHex = palettes.triadic.map((colorLCH) => formatHex(colorLCH));
// ["#ff007c", "#1f8a00", "#0091ff"]
👋 — Culori requires an explicit mode
on all color objects. You will notice this included in the code examples throughout this tutorial.
For our first function, that’s it! Let’s take a look at how we can use it in real life.
Practical application
One benefit of creating our color palettes with code (programmatically) is that it makes rapid prototyping/experimentation super easy. Say, for example, we were working on a design and got completely stuck with what color palette to use. Using our createScientificPalettes
function, alongside some simple CSS custom properties, we can generate near-infinite palettes and test them with our UI in real-time!
Here’s a CodePen to demonstrate:
Challenge
Right now, our createScientificPalettes
function accounts for all palette types, apart from monochromatic. Can you update it to support monochromatic palettes?
Function #2 — “Discovery”
So, this function is similar to the previous one but with quite a twist. We are still generating “classic” color combinations, but rather than calculating them scientifically (adding set “steps” to the hue of a base color), we are discovering them! That’s right; our discovery function will take an array of colors and find the best palette matches within it — analogous, triadic, tetradic, etc.
Here’s an illustrated example:
Using this function, we can discover beautiful palettes within images, color datasets, and more! Let’s see how it works.
The code
import {
nearest,
differenceEuclidean,
} from "https://cdn.skypack.dev/culori@2.0.0";
function isColorEqual(c1, c2) {
return c1.h === c2.h && c1.l === c2.l && c1.c === c2.c;
}
function discoverPalettes(colors) {
const palettes = {};
for (const color of colors) {
const targetPalettes = createScientificPalettes(color);
for (const paletteType of Object.keys(targetPalettes)) {
const palette = [];
let variance = 0;
for (const targetColor of targetPalettes[paletteType]) {
// filter out colors already in the palette
const availableColors = colors.filter(
(color1) => !palette.some((color2) => isColorEqual(color1, color2))
);
const match = nearest(
availableColors,
differenceEuclidean("lch")
)(targetColor)[0];
variance += differenceEuclidean("lch")(targetColor, match);
palette.push(match);
}
if (!palettes[paletteType] || variance < palettes[paletteType].variance) {
palettes[paletteType] = {
colors: palette,
variance
};
}
}
}
return palettes;
}
To break this down:
- Pass an array of LCH colors to the
discoverPalettes
function. - For every color, create the “optimum” target palettes based on it using our
createScientificPalettes
function. - For every palette, find the closest match for each of its colors. We calculate color matches here using Culori’s nearest and differenceEuclidian functions.
- Determine how similar/different the “discovered” palette is to the target. Keep a record of the closest palette matches.
- Return the closest match of each palette type!
Awesome! This method is super exciting, as it operates much as a human would — looking at a selection of colors and finding the best (but never perfect) palettes; this is great, as sometimes, purely mathematic color theory can appear a touch sterile/predictable.
Usage
As a quick reference, here’s how we could use discoverPalettes
with an array of HEX colors:
import {
converter,
} from "https://cdn.skypack.dev/culori@2.0.0";
const toLCH = converter("lch");
const baseColors = [
"#FFB97A",
"#FF957C",
"#FF727F",
"#FF5083",
"#F02F87",
"#C70084",
"#9A007F",
"#6A0076",
"#33006B"
];
const baseColorsLCH = baseColors.map((color) => toLCH(color));
const palettes = discoverPalettes(baseColorsLCH);
// { analogous: [...], complementary: [...], ... }
👋 — discoverPalettes
expects a minimum of four colors to function correctly.
Practical application
One of the most compelling aspects of discoverPalettes
is its ability to pull coherent color combinations out of just about any source. Here it is, discovering palettes based on images from Unsplash:
Cool eh? Extracting palettes from photographs is a fantastic way of working when stuck for ideas, and discoverPalettes
makes the process incredibly easy. This kind of approach, previously available only through “magic” color generators/apps, is now right at our fingers and ready to be tweaked, iterated, and improved to suit our own personal use-cases and preferences!
Challenge
Right now, our discoverPalettes
function finds the best matches it can in an array of colors, but it isn’t too easy to control. Can you add a degree of bias/weighting to its selection? How might you modify the function to prioritize brighter colors, for example?
Function #3 — “Hue Shift”
For our third and final function, we will be taking inspiration from the world of pixel art!
Often when adding shades/highlights to a sprite, pixel artists will not only modulate the lightness/chroma of a color (saturation if working with HSL) but also shift its hue. Here’s an excellent video on the subject, but in short, this is what it looks like:
So pretty! As a color becomes lighter, its hue shifts up; as it becomes darker, it shifts down. When applied subtly, this technique helps ensure shades/tints of a color are vivid and impactful. When “dialed up” a little, it is a fantastic way of generating stunning standalone color palettes.
The code
function adjustHue(val) {
if (val < 0) val += Math.ceil(-val / 360) * 360;
return val % 360;
}
function map(n, start1, end1, start2, end2) {
return ((n - start1) / (end1 - start1)) * (end2 - start2) + start2;
}
function createHueShiftPalette(opts) {
const { base, minLightness, maxLightness, hueStep } = opts;
const palette = [base];
for (let i = 1; i < 5; i++) {
const hueDark = adjustHue(base.h - hueStep * i);
const hueLight = adjustHue(base.h + hueStep * i);
const lightnessDark = map(i, 0, 4, base.l, minLightness);
const lightnessLight = map(i, 0, 4, base.l, maxLightness);
const chroma = base.c;
palette.push({
l: lightnessDark,
c: chroma,
h: hueDark,
mode: "lch"
});
palette.unshift({
l: lightnessLight,
c: chroma,
h: hueLight,
mode: "lch"
});
}
return palette;
}
To break this down into steps:
- Pass a base color, min/max lightness, and hue step parameters to a
createHueShiftPalette
function. The min/max lightness values determine how dark/light our palette will be at either extreme. The step controls how much the hue will shift at each color. - Store the base color in an array. In the illustration above, this is the middle color.
- Create a loop that iterates four times. Each iteration, add a darker shade to the start of the array and a lighter tint to the end. Here, we use
map
to calculate our lightness values — a function that takes a number that usually exists in one range and converts it to another — and increase or decrease the hue using ourhueStep
variable. Again,adjustHue
is used here to ensure all hue values are between 0 and 360. - Return the palette!
Usage
Once our createHueShiftPalette
function is defined, we can use it like so:
import { formatHex } from "https://cdn.skypack.dev/culori@2.0.0";
const hueShiftPalette = createHueShiftPalette({
base: {
l: 55,
c: 75,
h: 0,
mode: "lch"
},
minLightness: 10,
maxLightness: 90,
hueStep: 12
});
const hueShiftPaletteHex = hueShiftPalette.map((color) => formatHex(color));
// ["#ffb97a", "#ff957c", "#ff727f", "#ff5083", "#f02f87", "#c70084", "#9a007f", "#6a0076", "#33006b"]
Practical application
The palettes generated by createHueShiftPalette
work fantastically for patterns/graphics; here’s an example using it to create random/generative patterns that differ ever-so-slightly each time they render:
Cool, right? As just one example using this approach, we can create UI elements that are always fresh and unique to the current user — a lovely way to bring a little joy to the folks who use our websites/applications!
Challenge
Right now, the lightness/hue values scale linearly in our createHueShiftPalette
function. Could you apply some easing to them? Perhaps, starting with a larger/smaller hue shift and reducing/increasing it with each step?
Wrapping up
Well, folks, that’s all for now! We have learned how to create three beautiful color generation functions, seen how they can be applied and considered how they could be improved/changed. From here, I hope you take these functions and change them to suit you, and hopefully, even write your own!
As developers, we have a unique skill set that is perfect for creating truly innovative, stunning design. Whether that means creating a color generation tool for designers you work with or adding mind-blowing generative palettes to your website — we should all feel confident in our ability to work with color.
Until next time!