How to manipulate CSS colors with JavaScript

I know you’re here to learn about manipulating colors — and we’ll get there. But before we do, we need a baseline understanding of how CSS notates colors. CSS uses two different color models: RGB and HSL. Let’s take a quick look at both.

An initialism for “red, green, blue,” RGB consists of three numbers that each signify how much light of its respective color is included in the resulting end color. In CSS, each of these numbers is in the range of 0–255 and would be written as comma-separated parameters of the CSS rgb function. For example, rgb(50,100,0).

RGB is an “additive” color system, which means that the higher each number is, the brighter the end color will be. If all values are equal, the color will be grayscale; if all values are zero, the result will be black; and if all values are 255, the result will be white.

Alternatively, you can notate RGB colors using the hexadecimal notation, in which each color’s integer is converted from base 10 to base 16. For example, rgb(50,100,0) would be #326400.

Although I usually find myself reaching for RGB (particularly hexadecimal) out of habit, I often find that it is hard to read and especially hard to manipulate. Enter HSL.

An initialism for “hue, saturation, light,” HSL also consists of three values. The hue value corresponds to the position on the color wheel and is represented by a CSS angle value; most commonly, deg units are used.

Saturation, represented by a percentage, refers to the intensity of the color. When saturation is 100 percent, it is fully colored; the less saturation, the less color, until it reaches grayscale at 0 percent.

Lightness, also represented by a percentage, refers to how bright a color is. “Regular” brightness is 50 percent. A lightness of 100 percent will be pure white, and 0 percent lightness will be pure black, regardless of the hue and saturation values.

I find HSL to be a more intuitive model. Relations between colors are more immediately evident, and manipulation of colors tends to be as simple as tweaking just one of the numbers.

Both the RGB and HSL color models break down a color into various attributes. To convert between the syntaxes, we first need to calculate these attributes.

With the exception of hue, each value we have discussed can be represented as a percentage. Even the RGB values are byte-sized representations of percentages. In the formulas and functions below, these percentages will be represented by decimals between 0 and 1.

I would like to note that I will not cover the math for these in depth; rather, I will briefly go over the original mathematical formula and then convert it into a JavaScript formula.

Lightness is the easiest of the three HSL values to calculate. Mathematically, the formula is displayed as follows, where M is the maximum of the RGB values and m is the minimum:

The mathematic formula for lightness

Here is the same formula as a JavaScript function:

const rgbToLightness = (r,g,b) => 
1/2 * (Math.max(r,g,b) + Math.min(r,g,b));

Saturation is only slightly more complicated than lightness. If the lightness is either 0 or 1, then the saturation value will be 0. Otherwise, it follows the mathematical formula below, where L represents lightness:

The mathematical formula for saturation

As JavaScript:

const rgbToSaturation = (r,g,b) => {
const L = rgbToLightness(r,g,b);
const max = Math.max(r,g,b);
const min = Math.min(r,g,b);
return (L === 0 || L === 1)
? 0
: (max - min)/(1 - Math.abs(2 * L - 1));

The formula for calculating the hue angle from RGB coordinates is a bit more complex:

The mathematical formula for hue

As JavaScript:

const rgbToHue = (r,g,b) => Math.round(
Math.sqrt(3) * (g - b),
2 * r - g - b,
) * 180 / Math.PI

The multiplication of 180 / Math.PI at the end is to convert the result from radians to degrees.

All of these functions can be wrapped into a single utility function:

const rgbToHsl = (r,g,b) => {
const lightness = rgbToLightness(r,g,b);
const saturation = rgbToSaturation(r,g,b);
const hue = rgbToHue(r,g,b);
return [hue, saturation, lightness];

Before jumping into calculating RGB, we need a few prerequisite values.

First is the “chroma” value:

The mathematical formula for chroma

We also have a temporary hue value, whose range we will use to decide which “segment” of the hue circle we belong on:

The mathematical formula for hue prime

Next, we have an “x” value, which will be used as the middle (second-largest) component value:

The mathematical formula for a temporary “x” value

We have an “m” value, which is used to adjust each of the values for lightness:

The mathematical formula for lightness match

Depending on the hue prime value, the r, g, and b values will map to C, X, and 0:

The mathematical formula for RGB values without accounting for lightness

Lastly, we need to map each value to adjust for lightness:

The mathematical formula to account for lightness with RGB

Putting all of this together into a JavaScript function:

const hslToRgb = (h,s,l) => {
const C = (1 - Math.abs(2 * l - 1)) * s;
const hPrime = h / 60;
const X = C * (1 - Math.abs(hPrime % 2 - 1));
const m = l - C/2;
const withLight = (r,g,b) => [r+m, g+m, b+m];
if (hPrime <= 1) { return withLight(C,X,0); } else
if (hPrime <= 2) { return withLight(X,C,0); } else
if (hPrime <= 3) { return withLight(0,C,X); } else
if (hPrime <= 4) { return withLight(0,X,C); } else
if (hPrime <= 5) { return withLight(X,0,C); } else
if (hPrime <= 6) { return withLight(C,0,X); }

For ease of access when manipulating their attributes, we will be dealing with a JavaScript object. This can be created by wrapping the previously written functions:

const rgbToObject = (red,green,blue) => {
const [hue, saturation, lightness] = rgbToHsl(red, green, blue);
return {red, green, blue, hue, saturation, lightness};
const hslToObject = (hue, saturation, lightness) => {
const [red, green, blue] = hslToRgb(hue, saturation, lightness);
return {red, green, blue, hue, saturation, lightness};

I highly encourage you to spend some time playing with this example. Seeing how each of the attributes interacts when you adjust the others can give you a deeper understanding of how the two color models fit together.

Now that we have the ability to convert between color models, let’s look at how we can manipulate these colors!

Each of the color attributes we have covered can be manipulated individually, returning a new color object. For example, we can write a function that rotates the hue angle:

const rotateHue = rotation => ({hue,}) => {
const modulo = (x, n) => (x % n + n) % n;
const newHue = modulo(hue + rotation, 360);
return {, hue: newHue };

The rotateHue function accepts a rotation parameter and returns a new function, which accepts and returns a color object. This allows for the easy creation of new “rotation” functions:

const rotate30 = rotateHue(30);
const getComplementary = rotateHue(180);
const getTriadic = color => {
const first = rotateHue(120);
const second = rotateHue(-120);
return [first(color), second(color)];

Along the same lines, you can write functions to saturate or lighten a color — or, inversely, desaturate or darken.

const saturate = x => ({saturation,}) => ({,
saturation: Math.min(1, saturation + x),
const desaturate = x => ({saturation,}) => ({,
saturation: Math.max(0, saturation - x),
const lighten = x => ({lightness,}) => ({,
lightness: Math.min(1, lightness + x)
const darken = x => ({lightness,}) => ({,
lightness: Math.max(0, lightness - x)

In addition to color manipulation, you can write “predicates” — that is, functions that return a Boolean value.

const isGrayscale = ({saturation}) => saturation === 0;
const isDark = ({lightness}) => lightness < .5;

The JavaScript [].filter method accepts a predicate and returns a new array with all the elements that “pass.” The predicates we wrote in the previous section can be used here:

const colors = [/* ... an array of color objects ... */];
const isLight = ({lightness}) => lightness > .5;
const lightColors = colors.filter(isLight);

To sort an array of colors, you first need to write a “comparator” function. This function takes two elements of an array and returns a number to denote the “winner.” A positive number indicates that the first element should be sorted first, and a negative indicates the second should be sorted first. A zero value indicates a tie.

For example, here is a function for comparing the lightness of two colors:

const compareLightness = (a,b) => a.lightness - b.lightness;

Here is a function that compares saturation:

const compareSaturation = (a,b) => a.saturation - b.saturation;

In an effort to prevent duplication in our code, we can write a higher-order function to return a comparison function to compare any attribute:

const compareAttribute = attribute =>
(a,b) => a[attribute] - b[attribute];
const compareLightness = compareAttribute('lightness');
const compareSaturation = compareAttribute('saturation');
const compareHue = compareAttribute('hue');

You can average the specific attributes of an array of colors by composing various JavaScript array methods. First, you can calculate the average of an attribute by summing with reduce and dividing by the array length:

const colors = [/* ... an array of color objects ... */];
const toSum = (a,b) => a + b;
const toAttribute = attribute => element => element[attribute];
const averageOfAttribute = attribute => array => / array.length;

You can use this to “normalize” an array of colors:

/* ... continuing */
const normalizeAttribute = attribute => array => {
const averageValue = averageOfAttribute(attribute)(array);
const normalize = overwriteAttribute(attribute)(averageValue);
return normalize(array);
const normalizeSaturation = normalizeAttribute('saturation');
const normalizeLightness = normalizeAttribute('lightness');
const normalizeHue = normalizeAttribute('hue');

Colors are an integral part of the web. Breaking down colors into their attributes allows for the smart manipulation of colors and opens the door to all sorts of possibilities.

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

Try it for free.