Skip to content

12. Shade, Blend and Convert a Web Color (pSBC.js)

Pimp Trizkit edited this page Jan 8, 2022 · 31 revisions
<< Previous        Back to Table of Contents        Next >>

    "Shady deals are my thing... but your cool man." - PT

Dependency: pSBCr (embedded)

This will take a HEX or RGB web color. pSBC can shade it darker or lighter, or blend it with a second color, and can also pass it right thru but convert from Hex to RGB (Hex2RGB) or RGB to Hex (RGB2Hex). All without you even knowing what color format you are using.

This runs really fast, probably the fastest, especially considering its many features. It was a long time in the making. See the whole story below. If you want the absolutely smallest and fastest possible way to shade or blend, visit the Micro Functions and use one of the 2-liner speed demons. They are great for intense animations, but this version here is fast enough for most animations.

This function uses Log Blending or Linear Blending. However, it does NOT convert to HSL to properly lighten or darken a color. Therefore, results from this function will differ from those much larger and much slower functions that use HSL.

jsFiddle with pSBC

Features:

  • Auto-detects and accepts standard Hex colors in the form of strings. For example: "#AA6622" or "#bb551144".
  • Auto-detects and accepts standard RGB colors in the form of strings. For example: "rgb(123,45,76)" or "rgba(45,15,74,0.45)".
  • Shades colors to white or black by percentage.
  • Blends colors together by percentage.
  • Does Hex2RGB and RGB2Hex conversion at the same time, or solo.
  • Accepts 3 digit (or 4 digit w/ alpha) HEX color codes, in the form #RGB (or #RGBA). It will expand them. For Example: "#C41" becomes "#CC4411".
  • Accepts and (Linear) blends alpha channels. If either the c0 (from) color or the c1 (to) color has an alpha channel, then the returned color will have an alpha channel. If both colors have an alpha channel, then the returned color will be a linear blend of the two alpha channels using the percentage given (just as if it were a normal color channel). If only one of the two colors has an alpha channel, this alpha will just be passed thru to the returned color. This allows one to blend/shade a transparent color while maintaining the transparency level. Or, if the transparency levels should blend as well, make sure both colors have alphas. When shading, it will pass the alpha channel straight thru. If you want basic shading that also shades the alpha channel, then use rgb(0,0,0,1) or rgb(255,255,255,1) as your c1 (to) color (or their hex equivalents). For RGB colors, the returned color's alpha channel will be rounded to 3 decimal places.
  • RGB2Hex and Hex2RGB conversions are implicit when using blending. Regardless of the c0 (from) color; the returned color will always be in the color format of the c1 (to) color, if one exists. If there is no c1 (to) color, then pass 'c' in as the c1 color and it will shade and convert whatever the c0 color is. If conversion only is desired, then pass 0 in as the percentage (p) as well. If the c1 color is omitted or a falsy is passed in, it will not convert.
  • A secondary function is added to the pSBC object as well. pSBC.pSBCr can be passed a Hex or RGB color and it returns an object containing this color information. Its in the form: {r: XXX, g: XXX, b: XXX, a: X.XXX}. Where .r, .g, and .b have range 0 to 255. And when there is no alpha: .a is -1. Otherwise: .a has range 0.000 to 1.000.
  • For RGB output, it outputs rgba() over rgb() when a color with an alpha channel was passed into c0 (from) and/or c1 (to).
  • Minor Error Checking has been added. It's not perfect. It can still crash or create jibberish. But it will catch some stuff. Basically, if the structure is wrong in some ways or if the percentage is not a number or out of scope, it will return null. An example: pSBC(0.5,"salt") == null, where as it thinks #salt is a valid color. Delete the four lines which end with return null; to remove this feature and make it faster and smaller.
  • Uses Log Blending. Pass true in for l (the 4th parameter) to use Linear Blending.

Code:

// Version 4.1
const pSBC=(p,c0,c1,l)=>{
	let r,g,b,P,f,t,h,m=Math.round,a=typeof(c1)=="string";
	if(typeof(p)!="number"||p<-1||p>1||typeof(c0)!="string"||(c0[0]!='r'&&c0[0]!='#')||(c1&&!a))return null;
	h=c0.length>9,h=a?c1.length>9?true:c1=="c"?!h:false:h,f=pSBC.pSBCr(c0),P=p<0,t=c1&&c1!="c"?pSBC.pSBCr(c1):P?{r:0,g:0,b:0,a:-1}:{r:255,g:255,b:255,a:-1},p=P?p*-1:p,P=1-p;
	if(!f||!t)return null;
	if(l)r=m(P*f.r+p*t.r),g=m(P*f.g+p*t.g),b=m(P*f.b+p*t.b);
	else r=m((P*f.r**2+p*t.r**2)**0.5),g=m((P*f.g**2+p*t.g**2)**0.5),b=m((P*f.b**2+p*t.b**2)**0.5);
	a=f.a,t=t.a,f=a>=0||t>=0,a=f?a<0?t:t<0?a:a*P+t*p:0;
	if(h)return"rgb"+(f?"a(":"(")+r+","+g+","+b+(f?","+m(a*1000)/1000:"")+")";
	else return"#"+(4294967296+r*16777216+g*65536+b*256+(f?m(a*255):0)).toString(16).slice(1,f?undefined:-2)
}

pSBC.pSBCr=(d)=>{
	const i=parseInt;
	let n=d.length,x={};
	if(n>9){
		const [r, g, b, a] = (d = d.split(','));
	        n = d.length;
		if(n<3||n>4)return null;
		x.r=i(r[3]=="a"?r.slice(5):r.slice(4)),x.g=i(g),x.b=i(b),x.a=a?parseFloat(a):-1
	}else{
		if(n==8||n==6||n<4)return null;
		if(n<6)d="#"+d[1]+d[1]+d[2]+d[2]+d[3]+d[3]+(n>4?d[4]+d[4]:"");
		d=i(d.slice(1),16);
		if(n==9||n==5)x.r=d>>24&255,x.g=d>>16&255,x.b=d>>8&255,x.a=Math.round((d&255)/0.255)/1000;
		else x.r=d>>16,x.g=d>>8&255,x.b=d&255,x.a=-1
	}return x
};

v4 changelog

Usages:

// Setup:

let color1 = "rgb(20,60,200)";
let color2 = "rgba(20,60,200,0.67423)";
let color3 = "#67DAF0";
let color4 = "#5567DAF0";
let color5 = "#F3A";
let color6 = "#F3A9";
let color7 = "rgb(200,60,20)";
let color8 = "rgba(200,60,20,0.98631)";

// Tests:

/*** Log Blending ***/
// Shade (Lighten or Darken)
pSBC ( 0.42, color1 ); // rgb(20,60,200) + [42% Lighter] => rgb(166,171,225)
pSBC ( -0.4, color5 ); // #F3A + [40% Darker] => #c62884
pSBC ( 0.42, color8 ); // rgba(200,60,20,0.98631) + [42% Lighter] => rgba(225,171,166,0.98631)

// Shade with Conversion (use "c" as your "to" color)
pSBC ( 0.42, color2, "c" ); // rgba(20,60,200,0.67423) + [42% Lighter] + [Convert] => #a6abe1ac

// RGB2Hex & Hex2RGB Conversion Only (set percentage to zero)
pSBC ( 0, color6, "c" ); // #F3A9 + [Convert] => rgba(255,51,170,0.6)

// Blending
pSBC ( -0.5, color2, color8 ); // rgba(20,60,200,0.67423) + rgba(200,60,20,0.98631) + [50% Blend] => rgba(142,60,142,0.83)
pSBC ( 0.7, color2, color7 ); // rgba(20,60,200,0.67423) + rgb(200,60,20) + [70% Blend] => rgba(168,60,111,0.67423)
pSBC ( 0.25, color3, color7 ); // #67DAF0 + rgb(200,60,20) + [25% Blend] => rgb(134,191,208)
pSBC ( 0.75, color7, color3 ); // rgb(200,60,20) + #67DAF0 + [75% Blend] => #86bfd0

/*** Linear Blending ***/
// Shade (Lighten or Darken)
pSBC ( 0.42, color1, false, true ); // rgb(20,60,200) + [42% Lighter] => rgb(119,142,223)
pSBC ( -0.4, color5, false, true ); // #F3A + [40% Darker] => #991f66
pSBC ( 0.42, color8, false, true ); // rgba(200,60,20,0.98631) + [42% Lighter] => rgba(223,142,119,0.98631)

// Shade with Conversion (use "c" as your "to" color)
pSBC ( 0.42, color2, "c", true ); // rgba(20,60,200,0.67423) + [42% Lighter] + [Convert] => #778edfac

// RGB2Hex & Hex2RGB Conversion Only (set percentage to zero)
pSBC ( 0, color6, "c", true ); // #F3A9 + [Convert] => rgba(255,51,170,0.6)

// Blending
pSBC ( -0.5, color2, color8, true ); // rgba(20,60,200,0.67423) + rgba(200,60,20,0.98631) + [50% Blend] => rgba(110,60,110,0.83)
pSBC ( 0.7, color2, color7, true ); // rgba(20,60,200,0.67423) + rgb(200,60,20) + [70% Blend] => rgba(146,60,74,0.67423)
pSBC ( 0.25, color3, color7, true ); // #67DAF0 + rgb(200,60,20) + [25% Blend] => rgb(127,179,185)
pSBC ( 0.75, color7, color3, true ); // rgb(200,60,20) + #67DAF0 + [75% Blend] => #7fb3b9

/*** Other Stuff ***/
// Error Checking
pSBC ( 0.42, "#FFBAA" ); // #FFBAA + [42% Lighter] => null  (Invalid Input Color)
pSBC ( 42, color1, color5 ); // rgb(20,60,200) + #F3A + [4200% Blend] => null  (Invalid Percentage Range)
pSBC ( 0.42, {} ); // [object Object] + [42% Lighter] => null  (Strings Only for Color)
pSBC ( "42", color1 ); // rgb(20,60,200) + ["42"] => null  (Numbers Only for Percentage)
pSBC ( 0.42, "salt" ); // salt + [42% Lighter] => null  (A Little Salt is No Good...)

// Error Check Fails (Some Errors are not Caught)
pSBC ( 0.42, "#salt" ); // #salt + [42% Lighter] => #a5a5a500  (...and a Pound of Salt is Jibberish)

// Ripping
pSBC.pSBCr ( color4 ); // #5567DAF0 + [Rip] => [object Object] => {'r':85,'g':103,'b':218,'a':0.941}

Return:

A color. It will be derived from the input color(s). And shaded or blended or converted accordingly. Or null if invalid parameters are used.

Params:

pSBC(p,c0,c1,l)

p = < Percentage Float > * REQUIRED

  • Used, as a percentage, with typical range of -1.0 to 1.0. Out of range parameter will cause a return of null.
  • When shading, the range is -1.0 to 1.0. But when blending, it is 0.0 to 1.0. And if you want to use the converter only, then use a p value of 0 or 0.0.
  • When shading, Positive numbers will shade to white (lighten the color). Negative colors will shade to black (darken the color).
  • When shading, using exactly -1.0 or 1.0 will always shade to pure black or pure white, regardless of the from color.
  • When blending two colors, a negative p value will get converted to a positive one (absolute value).
  • When blending two colors, a p (percentage) value of 0.5 will be a perfect 50/50 blend of the two colors. Higher p values will produce a color closer to the to color, and p values less than 0.5 will produce a color more similar to the from color.
  • Using exactly 0.0 (or 0) will not shade/blend at all, the from color will be returned. Use this to disable the shade/blend and to use the Hex2RGB and RGB2Hex conversion only option.
  • Using 0 for p (percentage) and 'c' for the to color will enable the standard conversion only option, like a shim. However, you can also use a real color as the to color and with a p of 0, it will return the from color in whatever color format the to color is using, be it Hex or RGB. I call this a blind shim. Because you can still standardize the environment, but without knowing which color formats it uses. In fact, this whole function lets you manipulate colors without you knowing its color format.

c0 = < "from" Color String > * REQUIRED

  • Accepts color strings in the form of RGB colors with or without the alpha channel (the RGB color format). For example: rgb(23,4,55) or rgba(23,4,55,0.52).
  • Accepts color strings in the form of Hex colors with or without the alpha channel (the Hex color format). And it can also accept the short 3 or 4 digit notation. For example: #FF33DD or #FF33DDCC or #F3D or #F3DC.
  • Auto-detects Hex or RGB.

c1 = < "to" Color String > Optional

  • Accepts color strings in the form of RGB colors with or without the alpha channel (the RGB color format). For example: rgb(23,4,55) or rgba(23,4,55,0.52).
  • Accepts color strings in the form of Hex colors with or without the alpha channel (the hex color format). And it can also accept the short 3 or 4 digit notation. For example: #FF33DD or #FF33DDCC or #F3D or #F3DC.
  • Auto-detects Hex or RGB.
  • To enable the conversion only feature: Pass the single character 'c' as the to color, and pass 0 as the p (percentage). The auto-detect of the from color will know which direction to convert. Using 'c' is required because...
  • Omitting the to color (or using a falsy) will enable the shader-only mode.
  • Using a to color will enable the blender.
  • Using a to color of white or black is the same as using the shader.
  • Conversion between Hex2RGB and RGB2Hex is implicit. Therefore, if your color formats are different between your from color and your to color, the returned color will have a color format equal to the to color. If the to color is the single character 'c', then the returned color will be derived from only the from color and with its color format converted to the other. If there is no to argument at all (omitted), or if passed a falsy, it will not convert anything and will only attempt to shade.
  • Using the single character 'c' as your to color with a non-zero p (percentage) will allow you to convert and shade.

l = < UseLinear Boolean > Optional

  • Defaults to false. And will use Log Blending.
  • Pass in true to use Linear Blending.

The picture below will help show the difference in the two blending methods:


StackOverflow Archive Begin

(The below was archived from the StackOverflow Q&A that started it all)


TL;DR? --Want simple lighten/darken(shading)? Skip down to Version 2, pick the one for RGB or Hex. --Want a full featured shader/blender/converter with errorcheck and alpha and 3 Digit hex? Use Version 3 near the bottom.

Play with version 3.1: jsFiddle > pSBC (aka. shadeBlendConvert)

Version 3.1 at GitHub: Goto GitHub > PJs > pSBC


After some pondering... I decided to answer my own question. A year and a half later. This was truly an adventure with ideas from several helpful users, and I thank you all! This one is for the team! While its not necessarily the answer I was looking for. Because if what James Khoury is saying is true, then there is no true hex math in javascript, I have to use decimals, this double conversion is necessary. If we make this assumption, then this is probably the fastest way I've seen (or can think of) to lighten (add white) or darken (add black) an arbitrary RBG color by percentage. It also accounts for the issues Cool Acid mentioned on his answer to this question (it pads 0s). But this version calls toString only once. This also accounts for out of range (it will enforce 0 and 255 as limits).

But beware, the color input has to be EXACTLY 7 characters, like #08a35c. (or 6 if using the top version)

Thanks to Pablo for the inspiration and idea for using percentage. For this I will keep the function name the same! lol! However, this one is different, as it normalizes the percentage to 255 and thus adding the same amount to each color (more white). If you pass in 100 for percent it will make your color pure white. If you pass in 0 for percent, nothing will happen. If you pass in 1 for percent it will add 3 shades to all colors (2.55 shades per 1%, rounded). So your really passing in a percentage of white (or black, use negative). Therefore, this version allows you to lighten pure red (FF0000), for example.

I also used insight from Keith Mashinter's answer to this question: How to convert decimal to hex in JavaScript?

I removed some, seemly, unnecessary parenthesis. (like in the double ternary statement and in crafting G) Not sure if this will mess with the operator precedence in some environments. Tested good in FireFox.

function shadeColor1(color, percent) {	// deprecated. See below.
    var num = parseInt(color,16),
        amt = Math.round(2.55 * percent),
          R = (num >> 16) + amt,
          G = (num >> 8 & 0x00FF) + amt,
          B = (num & 0x0000FF) + amt;
    return (0x1000000 + (R<255?R<1?0:R:255)*0x10000 + (G<255?G<1?0:G:255)*0x100 + (B<255?B<1?0:B:255)).toString(16).slice(1);
}

Or, if you want it to handle the "#":

function shadeColor1(color, percent) {	// deprecated. See below.
    var num = parseInt(color.slice(1),16), amt = Math.round(2.55 * percent), R = (num >> 16) + amt, G = (num >> 8 & 0x00FF) + amt, B = (num & 0x0000FF) + amt;
    return "#" + (0x1000000 + (R<255?R<1?0:R:255)*0x10000 + (G<255?G<1?0:G:255)*0x100 + (B<255?B<1?0:B:255)).toString(16).slice(1);
}

Hows that for two lines of code?

EDIT: Fix B<->G swap goof. Thanks svachalek!


-- UPDATE - Version 2 with Blending --

A little over a year later, again, and its still going. But this time I think its done. Noting the problems mentioned about not using HSL to properly lighten the color. There is a technique that eliminates most of that inaccuracy without having to convert to HSL. The main problem is that a color channel will get fully saturated before the rest of the color. Causing a shift in the hue after that point. I found these questions here and here which got me on track. Mark Ransom's post showed me the difference, and Keith's post showed me the way. Lerp is the savior. It is the same as blending colors, so I created a blendColors function as well.


TL;DR - For simple lighten/darken use this function shadeHexColor below. Or its RGB counterpart shadeRGBColor further below, and give me one vote. But, if you want any and/or all the goodies. Such as the ability to use both RGB and Hex colors, Error Checking, 3 Digit hex decoding, Blending, Alpha Channels, and RGB2Hex / Hex2RGB conversions. Then, skip down to Version 3 for shadeBlendConvert to get all the bells and whistles and give me two votes. You can then delete a few lines to remove some of these features, if desired. And you get a vote if you remember that Version 1 shadeColor1 above is deprecated for all uses.


So, without further ado:

- Version 2 Hex -

function shadeHexColor(color, percent) {
    var f=parseInt(color.slice(1),16),t=percent<0?0:255,p=percent<0?percent*-1:percent,R=f>>16,G=f>>8&0x00FF,B=f&0x0000FF;
    return "#"+(0x1000000+(Math.round((t-R)*p)+R)*0x10000+(Math.round((t-G)*p)+G)*0x100+(Math.round((t-B)*p)+B)).toString(16).slice(1);
}
function blendHexColors(c0, c1, p) {
    var f=parseInt(c0.slice(1),16),t=parseInt(c1.slice(1),16),R1=f>>16,G1=f>>8&0x00FF,B1=f&0x0000FF,R2=t>>16,G2=t>>8&0x00FF,B2=t&0x0000FF;
    return "#"+(0x1000000+(Math.round((R2-R1)*p)+R1)*0x10000+(Math.round((G2-G1)*p)+G1)*0x100+(Math.round((B2-B1)*p)+B1)).toString(16).slice(1);
}

- Version 2 RGB -

function shadeRGBColor(color, percent) {
    var f=color.split(","),t=percent<0?0:255,p=percent<0?percent*-1:percent,R=parseInt(f[0].slice(4)),G=parseInt(f[1]),B=parseInt(f[2]);
    return "rgb("+(Math.round((t-R)*p)+R)+","+(Math.round((t-G)*p)+G)+","+(Math.round((t-B)*p)+B)+")";
}
function blendRGBColors(c0, c1, p) {
    var f=c0.split(","),t=c1.split(","),R=parseInt(f[0].slice(4)),G=parseInt(f[1]),B=parseInt(f[2]);
    return "rgb("+(Math.round((parseInt(t[0].slice(4))-R)*p)+R)+","+(Math.round((parseInt(t[1])-G)*p)+G)+","+(Math.round((parseInt(t[2])-B)*p)+B)+")";
}

Further ado:

There is no error checking, so values that get passed in which are out of range will cause unexpected results. As well, the color input has to be EXACTLY 7 characters, like #08a35c. But all the other goodies are still here like output range capping (00-FF outputs), padding (0A), handles #, and usable on solid colors, like #FF0000.

This new version of shadeColor takes in a float for its second parameter. For shadeHexColor and shadeRGBColor the valid range for the second (percent) parameter is -1.0 to 1.0.

And for blendHexColors and blendRGBColors the valid range for the third (percent) parameter is 0.0 to 1.0, negatives not allowed here.

This new version is no longer taking in a percentage of pure white, like the old version. Its taking in a percentage of the DISTANCE from the color given to pure white. In the old version, it was easy to saturate the color, and as a result, many colors would compute to pure white when using a sizable percentage. This new way, it only computes to pure white if you pass in 1.0, or pure black, use -1.0.

Calling blendHexColors(color, "#FFFFFF", 0.5) is the same as shadeHexColor(color,0.5). As well as, blendHexColors(color,"#000000", 0.5) is the same as shadeHexColor(color,-0.5). Just a touch slower.

The accuracy gained can be seen here:

Usages:

var color1 = "rbg(63,131,163)";
var lighterColor = shadeRGBColor(color1, 0.5);  //  rgb(159,193,209)
var darkerColor = shadeRGBColor(color1, -0.25); //  rgb(47,98,122)

var color2 = "rbg(244,128,0)";
var blend1 = blendRGBColors(color1, color2, 0.75);  //  rgb(199,129,41)
var blend2 = blendRGBColors(color2, color1, 0.62);  //  rgb(132,130,101)

- Version 2 Universal A -

function shade(color, percent){
    if (color.length > 7 ) return shadeRGBColor(color,percent);
    else return shadeHexColor(color,percent);
}
		
function blend(color1, color2, percent){
    if (color1.length > 7) return blendRGBColors(color1,color2,percent);
    else return blendHexColors(color1,color2,percent);
}

Usage:

var color1 = shade("rbg(63,131,163)", 0.5);
var color2 = shade("#3f83a3", 0.5);
var color3 = blend("rbg(63,131,163)", "rbg(244,128,0)", 0.5);
var color4 = blend("#3f83a3", "#f48000", 0.5);

- Version 2 Universal B -

Ok, fine! The popularity of this answer made me think I could do a much better Universal version of this. So here you go! This version is an All-In-One function copy/paste-able shader/blender for both RGB and Hex colors. This one is not really any different than the other Uni version provided above. Except that its much much smaller and just one function to paste and use. I think the size went from about 1,592 characters to 557 characters, if you compress it into one line. Of course, if you don't need to use it interchangeably between RGB and Hex, then you don't need a Universal version such as this anyhow, lol. Just use one of the much tinier and faster versions above; appropriate for your color scheme. Moving on... In some ways its a little faster, in some ways its a little slower. I didn't do any final speed test analysis. There are two usage differences: First, the percentage is now the first parameter of the function, instead of the last. Second, when blending, you can use negative numbers. They will just get converted to positive numbers.

No more ado:

function shadeBlend(p,c0,c1) {
    var n=p<0?p*-1:p,u=Math.round,w=parseInt;
    if(c0.length>7){
        var f=c0.split(","),t=(c1?c1:p<0?"rgb(0,0,0)":"rgb(255,255,255)").split(","),R=w(f[0].slice(4)),G=w(f[1]),B=w(f[2]);
        return "rgb("+(u((w(t[0].slice(4))-R)*n)+R)+","+(u((w(t[1])-G)*n)+G)+","+(u((w(t[2])-B)*n)+B)+")"
    }else{
        var f=w(c0.slice(1),16),t=w((c1?c1:p<0?"#000000":"#FFFFFF").slice(1),16),R1=f>>16,G1=f>>8&0x00FF,B1=f&0x0000FF;
        return "#"+(0x1000000+(u(((t>>16)-R1)*n)+R1)*0x10000+(u(((t>>8&0x00FF)-G1)*n)+G1)*0x100+(u(((t&0x0000FF)-B1)*n)+B1)).toString(16).slice(1)
    }
}

Usage:

var color1 = "#FF343B";
var color2 = "#343BFF";
var color3 = "rgb(234,47,120)";
var color4 = "rgb(120,99,248)";
var shadedcolor1 = shadeBlend(0.75,color1);
var shadedcolor3 = shadeBlend(-0.5,color3);
var blendedcolor1 = shadeBlend(0.333,color1,color2);
var blendedcolor34 = shadeBlend(-0.8,color3,color4); // Same as using 0.8

Now it might be perfect! ;) @ Mevin


- V2 Other Languages -

-- Swift Extension - RGB (by Matej Ukmar) --

    extension UIColor {
        func shadeColor(factor: CGFloat) -> UIColor {
            var r: CGFloat = 0
            var g: CGFloat = 0
            var b: CGFloat = 0
            var a: CGFloat = 0
            var t: CGFloat = factor < 0 ? 0 : 1
            var p: CGFloat = factor < 0 ? -factor : factor
            getRed(&r, green: &g, blue: &b, alpha: &a)
            r = (t-r)*p+r
            g = (t-g)*p+g
            b = (t-b)*p+b
            return UIColor(red: r, green: g, blue: b, alpha: a)
        }
    }

-- PHP Version - HEX (by Kevin M) --

    function shadeColor2($color, $percent) {
        $color = str_replace("#", "", $color);
        $t=$percent<0?0:255;
        $p=$percent<0?$percent*-1:$percent;
        $RGB = str_split($color, 2);
        $R=hexdec($RGB[0]);
        $G=hexdec($RGB[1]);
        $B=hexdec($RGB[2]);
        return '#'.substr(dechex(0x1000000+(round(($t-$R)*$p)+$R)*0x10000+(round(($t-$G)*$p)+$G​)*0x100+(round(($t-$B)*$p)+$B)),1);
    }

-- UPDATE -- Version 3.1 Universal --

(This has been added to my library at GitHub)

In a couple months it will have been yet another year since the last universal version. So... thanks to sricks's insightful comment. I have decided to take it to the next level, again. It's no longer the two line speed demon as it had started, lol. But, for what it does, it is quite fast and small. Its around 1600 bytes. If you remove ErrorChecking and remove 3 digit decoding you can get it down to around 1200 bytes and its faster. This is a lot of power in about a K. Just imagine, you could load this onto a Commodore64 and still have space for 50 more of them! (Disregarding the fact that the JavaScript Engine is larger than 63k)

Apparently there was more adoing to be doing:

// Version 3.1
const shadeBlendConvert = function (p, from, to) {
    if(typeof(p)!="number"||p<-1||p>1||typeof(from)!="string"||(from[0]!='r'&&from[0]!='#')||(to&&typeof(to)!="string"))return null; //ErrorCheck
    if(!this.sbcRip)this.sbcRip=(d)=>{
        let l=d.length,RGB={};
        if(l>9){
            d=d.split(",");
            if(d.length<3||d.length>4)return null;//ErrorCheck
            RGB[0]=i(d[0].split("(")[1]),RGB[1]=i(d[1]),RGB[2]=i(d[2]),RGB[3]=d[3]?parseFloat(d[3]):-1;
        }else{
            if(l==8||l==6||l<4)return null; //ErrorCheck
            if(l<6)d="#"+d[1]+d[1]+d[2]+d[2]+d[3]+d[3]+(l>4?d[4]+""+d[4]:""); //3 or 4 digit
            d=i(d.slice(1),16),RGB[0]=d>>16&255,RGB[1]=d>>8&255,RGB[2]=d&255,RGB[3]=-1;
            if(l==9||l==5)RGB[3]=r((RGB[2]/255)*10000)/10000,RGB[2]=RGB[1],RGB[1]=RGB[0],RGB[0]=d>>24&255;
        }return RGB;}
    var i=parseInt,r=Math.round,h=from.length>9,h=typeof(to)=="string"?to.length>9?true:to=="c"?!h:false:h,b=p<0,p=b?p*-1:p,to=to&&to!="c"?to:b?"#000000":"#FFFFFF",f=this.sbcRip(from),t=this.sbcRip(to);
    if(!f||!t)return null; //ErrorCheck
    if(h)return "rgb"+(f[3]>-1||t[3]>-1?"a(":"(")+r((t[0]-f[0])*p+f[0])+","+r((t[1]-f[1])*p+f[1])+","+r((t[2]-f[2])*p+f[2])+(f[3]<0&&t[3]<0?")":","+(f[3]>-1&&t[3]>-1?r(((t[3]-f[3])*p+f[3])*10000)/10000:t[3]<0?f[3]:t[3])+")");
    else return "#"+(0x100000000+r((t[0]-f[0])*p+f[0])*0x1000000+r((t[1]-f[1])*p+f[1])*0x10000+r((t[2]-f[2])*p+f[2])*0x100+(f[3]>-1&&t[3]>-1?r(((t[3]-f[3])*p+f[3])*255):t[3]>-1?r(t[3]*255):f[3]>-1?r(f[3]*255):255)).toString(16).slice(1,f[3]>-1||t[3]>-1?undefined:-2);
}

Play with version 4.0: jsFiddle > pSBC (aka. shadeBlendConvert)

The core math of this version is the same as before. But, I did some major refactoring. This has allowed for much greater functionality and control. It now inherently converts RGB2Hex and Hex2RGB.

All the old features from v2 above should still be here. I have tried to test it all, please post a comment if you find anything wrong. Anyhow, here are the new features for v3.0:

  • Accepts 3 digit (or 4 digit) HEX color codes, in the form #RGB (or #ARGB). It will expand them. Delete the line marked with //3 digit to remove this feature.
  • Accepts and blends alpha channels. If either the from color or the to color has an alpha channel, then the result will have an alpha channel. If both colors have an alpha channel, the result will be a blend of the two alpha channels using the percentage given (just as if it were a normal color channel). If only one of the two colors has an alpha channel, this alpha will just be passed thru to the result. This allows one to blend/shade a transparent color while maintaining the transparent level. Or, if the transparent level should blend as well, make sure both colors have alphas. Shading will pass thru the alpha channel, if you want basic shading that also blends the alpha channel, then use rgb(0,0,0,1) or rgb(255,255,255,1) as your to color (or their hex equivalents). For RGB colors, the resulting alpha channel will be rounded to 4 decimal places.
  • RGB2Hex and Hex2RGB conversions are now implicit when using blending. The result color will always be in the form of the to color, if one exists. If there is no to color, then pass 'c' in as the to color and it will shade and convert. If conversion only is desired, then pass 0 as the percentage as well.
  • A secondary function is added to the global as well. sbcRip can be passed a hex or rbg color and it returns an object containing this color information. Its in the form: {0:R,1:G,2:B,3:A}. Where R G and B have range 0 to 255. And when there is no alpha: A is -1. Otherwise: A has range 0.0000 to 1.0000.
  • Minor Error Checking has been added. It's not perfect. It can still crash. But it will catch some stuff. Basically, if the structure is wrong in some ways or if the percentage is not a number or out of scope, it will return null. An example: shadeBlendConvert(0.5,"salt") = null , where as it thinks #salt is a valid color. Delete the four lines marked with //ErrorCheck to remove this feature.
  • EDIT: Switched to use let, and an arrow function, and added this to sbcRip calls.

Usages:

let color1 = "rgb(20,60,200)";
let color2 = "rgba(20,60,200,0.67423)";
let color3 = "#67DAF0";
let color4 = "#5567DAF0";
let color5 = "#F3A";
let color6 = "#F3A9";
let color7 = "rgb(200,60,20)";
let color8 = "rgba(200,60,20,0.98631)";
let c;

// Shade (Lighten or Darken)
c = shadeBlendConvert ( 0.42, color1 ); // rgb(20,60,200) + [42% Lighter] => rgb(119,142,223)
c = shadeBlendConvert ( -0.4, color5 ); // #F3A + [40% Darker] => #991f66
c = shadeBlendConvert ( 0.42, color8 ); // rgba(200,60,20,0.98631) + [42% Lighter] => rgba(223,142,119,0.98631)
// Shade with Conversion (use "c" as your "to" color)
c = shadeBlendConvert ( 0.42, color2, "c" ); // rgba(20,60,200,0.67423) + [42% Lighter] + [Convert] => #778edfac
// RGB2Hex & Hex2RGB Conversion Only (set percentage to zero)
c = shadeBlendConvert ( 0, color6, "c" ); // #F3A9 + [Convert] => rgba(255,51,170,0.6)
// Blending
c = shadeBlendConvert ( -0.5, color2, color8 ); // rgba(20,60,200,0.67423) + rgba(200,60,20,0.98631) + [50% Blend] => rgba(110,60,110,0.8303)
c = shadeBlendConvert ( 0.7, color2, color7 ); // rgba(20,60,200,0.67423) + rgb(200,60,20) + [70% Blend] => rgba(146,60,74,0.67423)
c = shadeBlendConvert ( 0.25, color3, color7 ); // #67DAF0 + rgb(200,60,20) + [25% Blend] => rgb(127,179,185)
c = shadeBlendConvert ( 0.75, color7, color3 ); // rgb(200,60,20) + #67DAF0 + [75% Blend] => #7fb3b9
// Error Checking
c = shadeBlendConvert ( 0.42, "#FFBAA" ); // #FFBAA + [42% Lighter] => null  (Invalid Input Color)
c = shadeBlendConvert ( 42, color1, color5 ); // rgb(20,60,200) + #F3A + [4200% Blend] => null  (Invalid Percentage Range)
c = shadeBlendConvert ( 0.42, {} ); // [object Object] + [42% Lighter] => null  (Strings Only for Color)
c = shadeBlendConvert ( "42", color1 ); // rgb(20,60,200) + ["42"] => null  (Numbers Only for Percentage)
c = shadeBlendConvert ( 0.42, "salt" ); // salt + [42% Lighter] => null  (A Little Salt is No Good...)
// Error Check Fails (Some Errors are not Caught)
c = shadeBlendConvert ( 0.42, "#salt" ); // #salt + [42% Lighter] => #6b6b6b00  (...and a Pound of Salt is Jibberish)
// Ripping
c = sbcRip ( color4 ); // #5567DAF0 + [Rip] => [object Object] => {'0':85,'1':103,'2':218,'3':0.9412}

I now hesitate to call this done... again...

- MAJOR EDIT(3/9/18) - v3.1 -

I am so ashamed! (and surprised that no-one mentioned this) Apparently I don't use alpha channels in my own projects... AND... apparently I did terrible testing. The Version 3 did not read nor write colors with alpha channels correctly. There were a few points that I either just had wrong or never actually learned:

  • Hex colors with alpha are #RGBA (not #ARGB). Version 3 was reading and writing this backwards.
  • RGB colors with alphas must be rgba() and not rgb(); version 3 never output rgba().
  • Version 3 didn't accept rgba() but did accept alphas in rgb(), which should not happen.

I just now replaced Version 3 with Version 3.1 where these issues are addressed. I didn't post it as a separate function here; seeing as the old Version 3 should be removed from existence and replaced with this one. And so that is what I did. Version 3 above is actually Version 3.1.

All the old features from v3.0 are still here with these updates:

  • Properly reads and writes colors with alpha channels. Both Hex and RGB.
  • The to color now accepts a String Color or a falsy (which can still be undefined).
  • The function is now constant.

... I'm glad I hesitated to call it done again. Here we are, another year or so later ... still perfecting it...


StackOverflow Archive End

(From here on down will be the changelog/blog)


- Update - 2/18/2019 - Version 4.0

Here we go again! There has been much concern on the accuracy of this function. People have pointed to CIE(LAB) color space. And they like to mention TinyColor. Now, as I've said in the comments on StackOverflow, this function does not use HSL to "properly" lighten or darken a color. So you may not get the same results as you may get from TinyColor. But steps have been made to improve this, or at least... emulate it, a little. And with that in mind, I have made a new version of the function that allows for two types of blending math of which we are going to call Linear Blending and Log Blending. The previous versions above have all used Linear Blending.

I have some credits to give out for this. Mike 'Pomax' Kamermans in his comments back on 9/16/18 to my answer on StackOverflow. He helped give me a kick in the motivation to get this going. Even George Beier is hinting on this as well on 1/11/19 with his comment following Mikes. As well as henrik with his comments back on 4/11/16 to the original post.

Anyhow, Mike 'Pomax' gave me a quick javascript example function which uses Log blending instead. His function is both insightful and genius. The logic is inventive and the math is straightforward. In fact, I kind of felt a little stupid once I decoded it. His example just included the core math code of blending and not all the other features. It was smaller than my core math code, but, unfortunately, it is not comparably very fast, it is ~4x slower. I was able to rewrite it, remove function calls, brought it down even smaller, and speed it up till it was only ~2x slower. That is the smallest version I've ever seen! Unfortunately, this isn't enough to justify its speed. Too bad... that was some cool logic tho! Moving on, I rewrote it again with my logic but with Mikes more straightforward math. This new version of the core math code is smaller than my original, it is slightly faster than my original, and better refactored.

Here is the new fully featured universal version 4.0 (akin to version 3.1 above):

const pSBC=(p,c0,c1,l)=>{
	let r,g,b,P,f,t,h,i=parseInt,m=Math.round,a=typeof(c1)=="string";
	if(typeof(p)!="number"||p<-1||p>1||typeof(c0)!="string"||(c0[0]!='r'&&c0[0]!='#')||(c1&&!a))return null;
	if(!this.pSBCr)this.pSBCr=(d)=>{
		let n=d.length,x={};
		if(n>9){
			[r,g,b,a]=d=d.split(","),n=d.length;
			if(n<3||n>4)return null;
			x.r=i(r[3]=="a"?r.slice(5):r.slice(4)),x.g=i(g),x.b=i(b),x.a=a?parseFloat(a):-1
		}else{
			if(n==8||n==6||n<4)return null;
			if(n<6)d="#"+d[1]+d[1]+d[2]+d[2]+d[3]+d[3]+(n>4?d[4]+d[4]:"");
			d=i(d.slice(1),16);
			if(n==9||n==5)x.r=d>>24&255,x.g=d>>16&255,x.b=d>>8&255,x.a=m((d&255)/0.255)/1000;
			else x.r=d>>16,x.g=d>>8&255,x.b=d&255,x.a=-1
		}return x};
	h=c0.length>9,h=a?c1.length>9?true:c1=="c"?!h:false:h,f=this.pSBCr(c0),P=p<0,t=c1&&c1!="c"?this.pSBCr(c1):P?{r:0,g:0,b:0,a:-1}:{r:255,g:255,b:255,a:-1},p=P?p*-1:p,P=1-p;
	if(!f||!t)return null;
	if(l)r=m(P*f.r+p*t.r),g=m(P*f.g+p*t.g),b=m(P*f.b+p*t.b);
	else r=m((P*f.r**2+p*t.r**2)**0.5),g=m((P*f.g**2+p*t.g**2)**0.5),b=m((P*f.b**2+p*t.b**2)**0.5);
	a=f.a,t=t.a,f=a>=0||t>=0,a=f?a<0?t:t<0?a:a*P+t*p:0;
	if(h)return"rgb"+(f?"a(":"(")+r+","+g+","+b+(f?","+m(a*1000)/1000:"")+")";
	else return"#"+(4294967296+r*16777216+g*65536+b*256+(f?m(a*255):0)).toString(16).slice(1,f?undefined:-2)
}

Version 4 is somewhere between 1.3x to 3.5x faster than Version 3.1, depending on how its used. When compressed and made into one line; this new version is 1208 bytes (Version 3.1 is 1340). Here are the changes:

  • A new l (Linear) parameter has been added. It defaults to using Log blending. But if you pass true in for the 4th parameter (l) it will switch to Linear Blending.
  • The rip now stores the color information as {r: XXX, g: XXX, b: XXX, a: X.XXX} instead of {0: XXX, 1: XXX, 2: XXX, 3: X.XXXX}.

The under the hood changes are listed below, these changes don't affect its usage, just size and perfomance:

  • Instead of using 0x1000... to build the hex number, I switched to the base10 version of the numbers. It is smaller in this case.
  • Instead of using [0] and [1] (and so on) to store and access the color information from the rip, it now uses dot notation. Ie: f.r instead of f[0]. This one change was the single biggest speed up in this update, and saved space.
  • During shading and converting, the c1 color (which is the to color) will not rip the default white and black colors. Now that rip is hardcoded, this is faster at the cost of being a touch larger.
  • Cached typeof(c1)=="string". This is both smaller and faster.
  • Rewrote parts of the pSBCr ripping function. This is both smaller and faster for both RGB and HEX.
  • All of the blending math is now done in one spot, before the return. This is a little faster, smaller, slightly more readable, and allowed for easy switching between Log and Linear blending.
  • Switch variable declaration to let from var. And moved to the front. Probably not much of a perforamance enhancement except it allowed me to cache a conditional.

A note to point out about speed is that three things are happening during pSBC's execution:

  1. It rips the input colors. (It detects for RGB or HEX and rips accordingly.)
  2. It blends them.
  3. It writes the output color. (It detects needed output, RGB or HEX, and writes color accordingly.)

As far as speed is concerned. HEX rips faster than RGB. But, RGB writes faster than HEX. Therefore RGB2HEX is much slower than HEX2RGB.

Micro Functions (Version 4)

If you really want speed and size, you will have to use RGB not HEX. RGB is more straightforward and simple, HEX writes too slow and comes in too many flavors for a simple two-liner (IE. it could be a 3, 4, 6, or 8 digit HEX code). You will also need to sacrifice some features, no error checking, no HEX2RGB nor RGB2HEX. As well, you will need to choose a specific function (based on its function name below) for the color blending math, and if you want shading or blending. These functions do support alpha channels. And when both input colors have alphas it will Linear Blend them. If only one of the two colors has an alpha, it will pass it straight thru to the resulting color. Below are two liner functions that are incredibly fast and small:

const RGB_Linear_Blend=(p,c0,c1)=>{
	var i=parseInt,r=Math.round,P=1-p,[a,b,c,d]=c0.split(","),[e,f,g,h]=c1.split(","),x=d||h,j=x?","+(!d?h:!h?d:r((parseFloat(d)*P+parseFloat(h)*p)*1000)/1000+")"):")";
	return"rgb"+(x?"a(":"(")+r(i(a[3]=="a"?a.slice(5):a.slice(4))*P+i(e[3]=="a"?e.slice(5):e.slice(4))*p)+","+r(i(b)*P+i(f)*p)+","+r(i(c)*P+i(g)*p)+j;
}

const RGB_Log_Blend=(p,c0,c1)=>{
	var i=parseInt,r=Math.round,P=1-p,[a,b,c,d]=c0.split(","),[e,f,g,h]=c1.split(","),x=d||h,j=x?","+(!d?h:!h?d:r((parseFloat(d)*P+parseFloat(h)*p)*1000)/1000+")"):")";
	return"rgb"+(x?"a(":"(")+r((P*i(a[3]=="a"?a.slice(5):a.slice(4))**2+p*i(e[3]=="a"?e.slice(5):e.slice(4))**2)**0.5)+","+r((P*i(b)**2+p*i(f)**2)**0.5)+","+r((P*i(c)**2+p*i(g)**2)**0.5)+j;
}

const RGB_Linear_Shade=(p,c0)=>{
	var i=parseInt,r=Math.round,[a,b,c,d]=c0.split(","),n=p<0,t=n?0:255*p,P=n?1+p:1-p;
	return"rgb"+(d?"a(":"(")+r(i(a[3]=="a"?a.slice(5):a.slice(4))*P+t)+","+r(i(b)*P+t)+","+r(i(c)*P+t)+(d?","+d:")");
}

const RGB_Log_Shade=(p,c0)=>{
	var i=parseInt,r=Math.round,[a,b,c,d]=c0.split(","),n=p<0,t=n?0:p*255**2,P=n?1+p:1-p;
	return"rgb"+(d?"a(":"(")+r((P*i(a[3]=="a"?a.slice(5):a.slice(4))**2+t)**0.5)+","+r((P*i(b)**2+t)**0.5)+","+r((P*i(c)**2+t)**0.5)+(d?","+d:")");
}

Notes:

  • When blending two alpha channels, they use Linear Blending for the alpha channel blend.
  • Blending percentage (p) range is 0.0 to 1.0. Shading percentage range is -1.0 to 1.0, negative p shades to black, positive p shades to white.
  • If you want to shade and have the alphas blend (instead of passing straight thru), you will have to use a blend function and pass in white or black (with an alpha channel) as your c1 color.