Using JavaScript to Adjust Saturation and Brightness of RGB Colors

Avatar of Hugh Haworth
Hugh Haworth on (Updated on )

Lately I’ve been taking a look into designing with color (or “colour” as we spell it where I’m from in New Zealand). Looking at Adam Wathan and Steve Schroger’s advice on the subject, we find that we’re going to need more than just five nice looking hex codes from a color palette generator when building an application. We’re going to need a lot of grays and a few primary colors. From these primary colors we’ll want a variety of levels of brightness and saturation.

I’ve mainly been using hex codes or RGB colors when developing applications and I’ve found I get slowed down by trying to work out different levels of lightness and saturation from a single hue.  So, to save you from getting RSI by carefully moving the color picker in VS Code, or continually opening hexcolortool, let’s look at some code to help you manipulate those colors.

HSL values

An effective way to write web colors is to use HSL values, especially if you plan to alter the colors manually. HSL stands for hue, saturation, lightness. Using HSL, you can declare your hue as a number from 0 to 360. Then you can note down a percentage for saturation and lightness respectively. For instance:

div {
  background-color: hsl(155, 30%, 80%);
}

This will give you a light, muted, mint green color. What if we needed to throw some dark text over this div? We could use a color close to black, but consistent with the background. For example, we can grab the same HSL values and pull the lightness down to 5%: 

div {
  background-color: hsl(155, 30%, 80%);
  color: hsl(155, 30%, 5%);
}

Nice. Now we have text that is very close to black, but looks a bit more natural, and is tied to its background. But what if this wasn’t a paragraph of text, but a call-to-action button instead? We can draw some more attention by ramping up the saturation and lowering the lightness a little on the background:

.call-to-action {
  background-color: hsl(155, 80%, 60%);
  color: hsl(155, 30%, 5%);
}

Or, what if there was some text that wasn’t as important? We could turn back up the brightness on the text, and lower the saturation. This takes away some of the contrast and allows this less important text to fade into the background a bit more. That said, we need to be careful to keep a high enough contrast for accessibility and readability, so let’s lighten the background again:

div {
  background-color: hsl(155, 30%, 80%);
  color: hsl(155, 30%, 5%);
}

.lessimportant {
  color: hsl(155, 15%, 40%);
}

HSL values are supported in all major browsers and they are a superior way of defining colors compared to RGB. This is because they allow you to be more declarative with the hue, saturation and lightness of a color.

But, what if you’ve already committed to using RGB values? Or you get an email from your boss asking “is this going to work on IE 8?”

Libraries

There are a lot of great color libraries out there that are able to convert HSL values back into hex codes or RGB colors. Most of them also have a variety of manipulation functions to help build a color scheme.

Here is a list of some libraries I know:

  • If converting between formats is a problem, try colvertize by Philipp Mildenberger. It’s a lightweight library providing a lot of conversion methods and a few manipulation methods.
  • Then we have color, maintained by Josh Junon. This allows you to declare, process and extract colors using a fluent interface. It provides a variety of conversions and manipulation methods.
  • Another one is TinyColor by Brian Grinstead over at Mozilla, which can handle a whole lot of input types as well as utility functions. It also provides a few functions to help generate color schemes.

Also here is a great CSS-Tricks article on converting color formats.

Colour Grid Tool

Another option is you can try out a color tool I built called Colour Grid. To quote Refactoring UI, “As tempting as it is, you can’t rely purely on math to craft the perfect color palette.”

Naturally, after reading this, I built a React app to mathematically craft a color palette. Okay, it won’t solve all your problems, but it might start you off with some options. The app will create 100 different levels of saturation and lightness based the hue you select. You can either click a grid item to copy the hex code, or copy a color as a CSS custom property from a text area at the end. This could be something to try if you need a quick way to get variations from one or two hues. 

Here are some techniques I learned for processing RGB colors as well for if you are using RGB colors and need a way to transform them.

How to find the lightness of an RGB color

Disclaimer: This technique does not account for the intrinsic value of a hue. The intrinsic value of a hue is its inherent brightness before you’ve started adding any black or white. It’s illustrated by the fact pure yellow looks a lot brighter to us than a pure purple.

This technique produces the level of lightness based on a programmatic measure of how much white or black is mixed in. The perceived brightness is affected by more than this measure so remember to also use your eyes to judge the level of light you need.

The level of lightness of an RGB color can be worked out by finding the average of the highest and lowest of the RGB values, then dividing this by 255 (the middle color does not affect the lightness).

This will give you a decimal between zero and one representing the lightness. Here is a JavaScript function for this:

function getLightnessOfRGB(rgbString) {
  // First convert to an array of integers by removing the whitespace, taking the 3rd char to the 2nd last then splitting by ','
  const rgbIntArray = (rgbString.replace(/ /g, '').slice(4, -1).split(',').map(e => parseInt(e)));


  // Get the highest and lowest out of red green and blue
  const highest = Math.max(...rgbIntArray);
  const lowest = Math.min(...rgbIntArray);


  // Return the average divided by 255
  return (highest + lowest) / 2 / 255;
}

Here’s a CodePen using this function:

How to saturate an RGB color without changing lightness or hue

What can we do with our newfound ability to find the lightness of an RGB? It can help us saturate an RGB color without changing the lightness.

Saturating an RGB comes with a few problems, though:

  • There is no information in the RGB format of a gray color to tell us what the saturated version will look like because gray doesn’t have a hue. So if we’re going to write a function to saturate a color, we need to deal with this case.
  • We can’t actually get to a pure hue unless the color is 50% lightness — anything else will be diluted by either black or white. So we have a choice of whether to keep the same lightness as we saturate the color, or move the color towards 50% lightness to get the most vibrant version. For this example, we’ll keep the same level of lightness.

Let’s start start with the color rgb(205, 228, 219) — a light, muted cyan. To saturate a color we need to increase the difference between the lowest and highest RGB value. This will move it toward a pure hue.

If we want to keep the lightness the same, we’re going to need to increase the highest value and decrease the lowest value by an equal amount. But because the RGB values need to be clamped between 0 and 255, our saturation options will be limited when the color is lighter or darker. This means there is a range of saturation we have available for any given lightness.

Let’s grab the saturation range available for our color. We can work it out by finding the lowest of these two:

  • The difference between the RGB values of a gray with the same lightness as our color, and 255
  • The difference between the RGB values of a gray with the same lightness as our color, and 0 (which is just the gray value itself)

To get a fully gray version of a color, we can grab the end result of the getLightnessOfRGB function from the previous section and multiply it by 255. Then use this number for all three of our RGB values to get a gray that’s the same lightness as our original color. 

Let’s do this now:

// Using the previous "getLightnessOfRGB" function
const grayVal = getLightnessOfRGB('rgb(205, 228, 219)')*255; // 217
// So a gray version of our color would look like rgb(217,217,217);
// Now let's get the saturation range available:
const saturationRange =  Math.round(Math.min(255-grayVal,grayVal)); // 38

Let’s say we want to saturate the color by 50%. To do this  want to increase the highest RGB value and decrease the lowest by 50% of the saturation range. However, this may put us over 255 or under zero, so we need to clamp the change by the minimum of these two values:

  • The difference between the highest RGB value and 255
  • The difference between the lowest RGB value and 0 (which is the value itself)
// Get the maximum change by getting the minimum out of: 
// (255 - the highest value) OR (the lowest value)
const maxChange = Math.min(255-228, 205); // 27


// Now we will be changing our values by the lowest out of:
// (the saturation range * the increase fraction) OR (the maximum change)
const changeAmount = Math.min(saturationRange/0.5, maxChange) // 19

This means we need to add 19 to the highest RGB value (green) and subtract 19 from the lowest RGB value:

const redResult = 205 - 19; // 186
const greenResult= 228 + 19; // 247

What about the third value?

This is where things get a bit more complicated. The middle value’s distance from gray can be worked with the ratio between it and the distance from gray of either of the other two values.

As we move the highest and lowest values further away from gray, the middle value increases/decreases in proportion with them. 

Now let’s get the difference between the highest value and full gray. Then the difference between the middle value and the full gray. Then we’ll get the ratio between these. I’m going to also remove the rounding from working out the gray value to make this more exact:

const grayVal = getLightnessOfRGB('rgb(205, 228, 219)')*255;
const highDiff = grayVal - 228; // -11 subtracting green - the highest value
const midDiff = grayVal - 219; // -2 subtracting blue - the middle value
const middleValueRatio = midDiff / highDiff; // 0.21739130434782608

Then what we need to do is get the difference between our new RGB green value (after we added 19 to it) and the gray value, then multiply this by our ratio. We add this back on to the gray value and that’s our answer for our newly saturated blue:

// 247 is the green value after we applied the saturation transformation
const newBlue = Math.round(grayVal+(247-grayVal)*middleValueRatio); // 223

So after we’ve applied our transformations, we we get an RGB color of rgb(186, 247, 223 — a more vibrant version of the color we started with. But its kept its lightness and hue.

Here are a couple of JavaScript functions that work together to saturate a color by 10%. The second function here returns an array of objects representing the RGB values in order of size. This second function is used in all of the rest of the functions in this article.

If you give it a gray, it will just return the same color:

function saturateByTenth(rgb) {
  const rgbIntArray = (rgb.replace(/ /g, '').slice(4, -1).split(',').map(e => parseInt(e)));
  const grayVal = getLightnessOfRGB(rgb)*255;
  const [lowest,middle,highest] = getLowestMiddleHighest(rgbIntArray);


  if(lowest.val===highest.val){return rgb;}
  
  const saturationRange =  Math.round(Math.min(255-grayVal,grayVal));
  const maxChange = Math.min((255-highest.val),lowest.val);
  const changeAmount = Math.min(saturationRange/10, maxChange);
  const middleValueRatio =(grayVal-middle.val)/(grayVal-highest.val);
  
  const returnArray=[];
  returnArray[highest.index]= Math.round(highest.val+changeAmount);
  returnArray[lowest.index]= Math.round(lowest.val-changeAmount);
  returnArray[middle.index]= Math.round(grayVal+(returnArray[highest.index]-grayVal)*middleValueRatio);
   return (`rgb(${[returnArray].join()})`);
}


function getLowestMiddleHighest(rgbIntArray) {
  let highest = {val:-1,index:-1};
  let lowest = {val:Infinity,index:-1};


  rgbIntArray.map((val,index)=>{
    if(val>highest.val){
      highest = {val:val,index:index};
    }
    if(val<lowest.val){
      lowest = {val:val,index:index};
    }
  });


  if(lowest.index===highest.index){
    lowest.index=highest.index+1;
  }
  
  let middle = {index: (3 - highest.index - lowest.index)};
  middle.val = rgbIntArray[middle.index];
  return [lowest,middle,highest];
}

How to desaturate an RGB Color

If we completely desaturate a color, we’ll end up with a shade of gray. RGB grays will always have three equal RGB values, so we could just use the grayVal from the previous function to make a gray color with the same lightness as any given color.

What if we don’t want to go straight to gray, and only want to slightly desaturate a color? We can do this by reversing the previous example.

Let’s look at another example. If we start with rgb(173, 31, 104), we have a saturated rouge. Let’s grab the decimal measure of lightness and multiply it by 255 to get the gray version:

const grayVal = Math.round(getLightnessOfRGB('rgb(173, 31, 104)') * 255); // 102

This means that if we fully desaturate this color to gray we’re going to end up with rgb(102, 102, 102). Let’s desaturate it by 30%.

First, we need to find the saturation range of the color again:

const saturationRange = Math.round(Math.min(255-grayVal,grayVal)); // 102

To desaturate our color by 30%, we want to move the highest and lowest color by 30% of this range toward full gray. But we also need to clamp the change amount by the distance between either of these colors (the distance will be the same for the highest and lowest), and full gray.

// Get the maximum change by getting the difference between the lowest (green) and the gray value
const maxChange = grayVal-31; // 71
// Now grab the value that represents 30% of our saturation range
const changeAmount = Math.min(saturationRange * 0.3, maxChange) // 30.59999

And add this change amount to the lowest RGB value and subtract it from the highest value: 

const newGreen =Math.Round(31+changeAmount); // 62
const newRed =Math.Round(173-changeAmount); // 142

Then use the same ratio technique as the last function to find the value for the third color:

const highDiff = grayVal - 173; // -71 subtracting red - the highest value
const midDiff = grayVal - 104; // -2 subtracting blue - the middle value
const middleValueRatio = midDiff / highDiff; // 0.02816901408
const newBlue = Math.Round(grayVal+(142.4-grayVal)*middleValueRatio); // 103

So that means the RGB representation of our rouge desaturated by 30% would be rgb(142, 62, 103). The hue and the lightness are exactly the same, but it’s a bit less vibrant.

Here’s a JavaScript function that will desaturate a color by 10%. It’s basically a reverse of the previous function.

function desaturateByTenth(rgb) {
  const rgbIntArray = (rgb.replace(/ /g, '').slice(4, -1).split(',').map(e => parseInt(e)));
  //grab the values in order of magnitude 
  //this uses the getLowestMiddleHighest function from the saturate section
  const [lowest,middle,highest] = getLowestMiddleHighest(rgbIntArray);
  const grayVal = getLightnessOfRGB(rgb) * 255;


  if(lowest.val===highest.val){return rgb;}
  
  const saturationRange =  Math.round(Math.min(255-grayVal,grayVal));
  const maxChange = grayVal-lowest.val;
  const changeAmount = Math.min(saturationRange/10, maxChange);
                               
  const middleValueRatio =(grayVal-middle.val)/(grayVal-highest.val);
  
  const returnArray=[];
  returnArray[highest.index]= Math.round(highest.val-changeAmount);
  returnArray[lowest.index]= Math.round(lowest.val+changeAmount);
  returnArray[middle.index]= Math.round(grayVal+(returnArray[highest.index]-grayVal)*middleValueRatio);
  return (`rgb(${[returnArray].join()})`);
}



Here’s a CodePen to experiment with the effect of these saturation functions:

How to lighten an RGB color keeping the hue the same

To lighten an RGB value and keep the hue the same, we need to increase each RGB value by the same proportion of difference between the value and 255. Let’s say we have this color: rgb(0, 153, 255). That’s a fully saturated blue/cyan. Let’s look at the difference between each RGB value and 255: 

  • Red is zero, so the difference is 255. 
  • Green is 153, so the difference is 102. 
  • Blue is 255, so the difference is zero. 

Now when we lighten the color, we need to increase each RGB value by the same fraction of our differences. One thing to note is that we are essentially mixing white into our color. This means that the color will slowly lose its saturation as it lightens.

Let’s increase the lightness on this color by a tenth. We’ll start with out lowest RGB value, red. We add on a tenth of 255 to this value. We also need to use Math.min to make sure that the value doesn’t increase over 255:

const red = 0;
const newRed = Math.round( red + Math.min( 255-red, 25.5 )); // 26

Now the other two RGB values need to increase by the same fraction of distance to 255.

To work this out, we get the difference between the lowest RGB value (before we increased it) and 255. Red was zero so our difference is 255. Then we get the amount the lowest RGB value increased in our transformation. Red increased from zero to 26, so our increase is 26.

Dividing the increase by the difference between the original color and 255 gives us a fraction we can use to work out the other values.

const redDiff = 255 - red; // 255
const redIncrease = newRed - red; // 26
const increaseFraction = redIncrease / redDiff; // 0.10196

Now we multiply the difference between the other RGB values and 255 by this fraction. This gives us the amount we need to add to each value.

const newGreen = Math.round(153 + (255 - 153) * increaseFraction); // 163
const newBlue = Math.round(255 + (255 - 255) * increaseFraction); // 255

This means the color we end up with is rgb(26, 163, 255). That’s still the same hue, but a touch lighter.

Here’s a function that does this: 

function lightenByTenth(rgb) {

  const rgbIntArray = rgb.replace(/ /g, '').slice(4, -1).split(',').map(e => parseInt(e));
  // Grab the values in order of magnitude 
  // This uses the getLowestMiddleHighest function from the saturate section
  const [lowest,middle,highest]=getLowestMiddleHighest(rgbIntArray);
  
  if(lowest.val===255){
    return rgb;
  }
  
  const returnArray = [];

  // First work out increase on lower value
  returnArray[lowest.index]= Math.round(lowest.val+(Math.min(255-lowest.val,25.5)));

  // Then apply to the middle and higher values
  const increaseFraction  = (returnArray[lowest.index]-lowest.val)/ (255-lowest.val);
  returnArray[middle.index]= middle.val +(255-middle.val)*increaseFraction ;
  returnArray[highest.index]= highest.val +(255-highest.val)*increaseFraction ;
  
  // Convert the array back into an rgb string
  return (`rgb(${returnArray.join()})`);
}

How to darken an RGB color keeping the hue the same

Darkening an RGB color is pretty similar. Instead of adding to the values to get 255, we’re subtracting from the values to get toward zero.

Also we start our transformation by reducing the highest value and getting the fraction of this decrease. We use this fraction to reduce the other two values by their distance to zero. This is a reversal of what we did lightening a color.

Darkening a color will also cause it to slowly lose its level of saturation.

function darkenByTenth(rgb) {
  
  // Our rgb to int array function again
  const rgbIntArray = rgb.replace(/ /g, '').slice(4, -1).split(',').map(e => parseInt(e));
  //grab the values in order of magnitude 
  //this uses the function from the saturate function
  const [lowest,middle,highest]=getLowestMiddleHighest(rgbIntArray);
  
  if(highest.val===0){
    return rgb;
  }

  const returnArray = [];

  returnArray[highest.index] = highest.val-(Math.min(highest.val,25.5));
  const decreaseFraction  =(highest.val-returnArray[highest.index])/ (highest.val);
  returnArray[middle.index]= middle.val -middle.val*decreaseFraction; 
  returnArray[lowest.index]= lowest.val -lowest.val*decreaseFraction;              
                            
  // Convert the array back into an rgb string
  return (`rgb(${returnArray.join()}) `);
}

Here’s a CodePen to experiment with the effect of the lightness functions:


If you ever do need to work with RGB colors, these functions will help you get you started. You can also give the HSL format a try, as well as the color libraries to extend browser support, and the Colour Grid tool for conversions.