Blending In With The Crowd
Implementing CSS and Photoshop blend modes in Javascript
There are many tutorials out there on how to apply blend modes in Photoshop, and vague descriptions of characteristics of the resulting image. but little info on how to recreate the effects in code. This guide shows how to implement the most common modes in javascript, and hopefully leave you well-positioned to create your own blend modes should the need arise. Along with this article, there is a site demonstrating all of the blend mode implementations. As you read this article I suggest you keep a tab with the companion site open.
About the code
To help make the code in the article easier to read and understand, I have written it in vanilla JavaScript. The demo site implements the blend modes with HTML canvas using GPU.js. This makes the operations on the pixels run in parallel, which is much faster. You can see how it is implemented in the site’s repository (maybe give it a star while you’re there 😁). You can also read the article Look at this Photograph, which explains the “how?” and “why?” of using GPU.js to process images.
The majority of the blend modes described here are based on the W3C specification which contains the math used for the CSS implementations. Photoshop, however, does not provide such a specification, so modes that are not present in the W3C spec are my interpretations of Adobe’s descriptions. Also, the terminology used by Adobe and the W3C differs a bit. To stay consistent, I have chosen to use the W3C terminology throughout this article.
The Basics
Spoiler alert… A blend mode is just a function that takes two colors and returns a color. That’s it. To get a feel for it let’s look at the normal blend mode.
function normal(backdrop, source) {
const [red, green, blue] = source;
return [red, green, blue];
}
This implementation is more complex even than necessary. It’s just written this way to use some terminology and help show exactly what’s going on. The normal
function takes a backdrop
color and a source
color. These colors are arrays of red, green, and blue values ranging from 0 to 1, and the return value is just the source color. In this case, the backdrop
doesn’t contribute to the resulting color at all. You might ask “What is the point of the normal blend mode?” Let’s first think about where these colors are coming from. Most likely they are pixels in some sort of raster format. When the rasters are layered we want to blend the corresponding pixels. The normal
blend mode completely obscures the bottom (backdrop) layer with the top (source) layer, as would happen if you placed real-life photographs one on top of the other. So when you place cutouts of your face over the faces of models in a bodybuilding magazine that’s the normal blend mode at work.
Arithmetic Modes
Now that we have a basic understanding, we’re going back to elementary school for the next four blend modes. They are Add, Subtract, Multiply, and Divide. These blend modes apply their namesake operation to the respective channels for each pixel. For example, the add mode adds the red channel in the backdrop pixel to the red channel in the source pixel to produce the red channel of the output pixel, and the same happens for the blue and green channels. Just as in arithmetic, the add and multiply blend modes are commutative, i.e. they produce the same result if the source and backdrop are swapped. The subtract and divide modes are not commutative, so they will produce different results depending on which pixel is source or backdrop.
function add(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
red1 + red2,
green1 + green2,
blue1 + blue2
];
}
function subtract(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
red1 - red2,
green1 - green2,
blue1 - blue2
];
}
function multiply(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
red1 * red2,
green1 * green2,
blue1 * blue2
];
}
function divide(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
red1 / red2,
green1 / green2,
blue1 / blue2
];
}
The add and divide modes will result in a brighter image than either of the input images. The subtract and multiply modes will result in a darker image. This is because RGB is an additive color system, which you can read more about here.
Another thing to note about these blend modes is that the same operation is applied to each channel. This is called a separable blend mode because the operation is applied to each channel separately. We will see more examples of non-separable blend modes later on.
Lighten, Darken, Difference and Exclusion
The Lighten, Darken, and Difference blend modes could also be considered part of the arithmetic blend modes. The three of them are commutative and separable. The lighten blend mode uses the maximum value from each channel to produce the output channel. The darken mode uses the minimum value from each channel. Difference mode takes the absolute value one channel minus the other channel. I’ve also included exclusion here because of its similar output to the difference mode. It adds both channel values together before subtracting a scaled multiple of their values. By adding the channels together before subtracting the output is guaranteed to be greater than or equal to zero, so no Math.abs
is needed. The resulting image has less contrast than the difference mode. Here are the implementations.
function lighten(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
Math.max(red1, red2),
Math.max(green1, green2),
Math.max(blue1, blue2)
];
}
function darken(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
Math.min(red1, red2),
Math.min(green1, green2),
Math.min(blue1, blue2)
];
}
function difference(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
Math.abs(red1 - red2),
Math.abs(green1 - green2),
Math.abs(blue1 - blue2)
];
}
function exclusion(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
red1 + red2 - 2 * red1 * red2,
green1 + green2 - 2 * green1 * green2,
blue1 + blue2 - 2 * blue1 * blue2,
pix2[3]
];
}
Dodging and Burning
Now we come to blend modes that come from a more artistic tradition. Dodging and burning are terms that originate in darkroom photography. In a darkroom, a photograph is developed by using light to project a negative onto photographic paper. The more light that photographic paper receives the darker it gets, thus creating the inverse of the negative.¹ The images can be manipulated by adjusting the amount and duration of light received by parts of the photographic paper. Burning is the technique that allows parts of the photograph to receive more light and therefore become darker. The dodging technique is the blocking of light hitting the photo, making the dodged sections of the photograph lighter.
There are four blend modes for dodging and burning: Color Dodge, Color Burn, Linear Dodge, and Linear Burn. The terms ‘linear’ and ‘color’ in these modes are related to the concepts of brightness and contrast. Brightness is a measure taken on a single color. Contrast is a measure of the difference in brightness between two colors. Therefore the input colors in the ‘linear’ variants are independent in how they affect the brightness of the resulting color. The inputs to the ‘color’ variants depend on each other in how much they affect the brightness of the resulting color.
function colorBurn(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
1 - (1 - red1) / red2,
1 - (1 - green1) / green2,
1 - (1 - blue1) / blue2
];
}
function colorDodge(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
pix1 / (1 - pix2)
pix1 / (1 - pix2)
pix1 / (1 - pix2)
];
}
function linearBurn(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
red1 + red2 - 1,
green1 + green2 - 1,
blue1 + blue2 - 1,
];
}
function linearDodge(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
red1 + red2,
green1 + green2,
blue1 + blue2,
];
}
Notice that linearDodge
is the same thing as the add mode.
Screen, Overlay, Hard Light, Soft Light
Screen mode is intended to provide an effect similar to multiply, but lighten the image rather than darken it. Of course, the divide mode could be seen as the opposite of multiply, but visually the results are not similar. Instead, the screen mode inverts each channel (subtracts it from one) before multiplying them together and then inverts the result. This causes the image to get brighter while still allowing for a multiplication of the channels. This results in far less contrast than the divide mode making for a more subtle effect.
Overlay mode is different from the blend modes we’ve seen so far. It applies a different formula depending on if the backdrop channels are above or below some threshold. If a channel is greater than 0.5 then a screen formula is used, otherwise, a multiply formula is used.
Hard Light is the inverse of overlay. Whether the screen or multiply formula is used depends on the source value rather than the backdrop value.
Soft Light, as described by the W3C, is “similar to shining a diffused spotlight on the backdrop”, which feels pretty accurate. It reminds me of trying to watch a movie from a projector in a room that is too bright. You can see the movie, but the surface it is projected on is distractingly visible. Soft Light is the most complex mode to implement that we’ve seen so far. The implementation I’ve provided below creates a _softLight
function in closure so the formula doesn’t need to be written for each channel. This approach could be taken for any of the separable blend modes, but I’ve only opted for it here because it doesn’t save much on lines of code in the others.
function screen(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
return [
1 - (1 - red1) * (1 - red2),
1 - (1 - green1) * (1 - green2),
1 - (1 - blue1) * (1 - blue2)
];
}
function overlay(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
const red = red1 < 0.5 ?
red1 * red2 * 2 :
1 - 2 * (1 - red1) * (1 - red2);
const green = green1 < 0.5 ?
green1 * green2 * 2 :
1 - 2 * (1 - green1) * (1 - green2);
const blue = blue1 < 0.5 ?
blue1 * blue2 * 2 :
1 - 2 * (1 - blue1) * (1 - blue2);
return [red, green, blue];
}
function hardLight(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
const red = red2 < 0.5 ?
red1 * red2 * 2 :
1 - 2 * (1 - red1) * (1 - red2);
const green = green2 < 0.5 ?
green1 * green2 * 2 :
1 - 2 * (1 - green1) * (1 - green2);
const blue = blue2 < 0.5 ?
blue1 * blue2 * 2 :
1 - 2 * (1 - blue1) * (1 - blue2);
return [red, green, blue];
}
function softLight(pixel1, pixel2) {
const [red1, green1, blue1] = pixel1;
const [red2, green2, blue2] = pixel2;
function _softLight(c1, c2) {
let c = 0;
if (c2 <= 0.5) {
c = c1 - (1 - 2 * c2) * c1 * (1 - c1);
} else {
let d = 0;
if (c1 <= 0.25) {
d = ((16 * c1 - 12) * c1 + 4) * c1;
} else {
d = Math.sqrt(c1);
}
red = c1 + (2 * c2 - 1) * (d - c1);
}
return c;
}
const red = _softLight(red1, red2);
const green = _softLight(green1, green2);
const blue = _softLight(blue1, blue2);
return [red, green, blue];
}
The Non-Separable Modes
Now we come to the non-separable blend modes. Rather than applying the same transformation to each pixel channel (red, green, blue), these modes calculate a new color based on some combination of the channels. The non-separable blend modes in the W3C specification are Hue, Saturation, Luminosity, and Color. For each of these modes the saturation, luminance, and hue are calculated and used to create the output color.
Saturation and luminance are measures of perceived attributes of color.² Saturation is a measure of the intensity of the color. The more perceived grayness a color has the less saturated it is. It is calculated as the difference between the maximum component and the minimum component. To help see why this calculation makes sense compare a pure gray color and a pure red. Pure gray is when the red, green, and blue components all share the same value. It has no saturation. Pure red on the other hand is considered to be fully saturated. So the more varied the components are, the less gray that will be present in the color.
` Pure Gray: red = 0.5, green = 0.5, blue = 0.5; saturation(0.5, 0.5, 0.5) = max(0.5, 0.5, 0.5) - min(0.5, 0.5, 0,5) = 0.5 - 0.5 = 0
Pure Red: red = 1, green = 0; blue = 0; saturation(1, 0, 0) = max(1, 0, 0) - min(1, 0, 0) = 1 - 0 = 1 `
Luminance is a measure of the amount of light intensity. The lum
function defined here isn’t an exact measure of luminance. It is a way to measure the preceived brightness of a color, so colors that appear similar to the human eye will have a similar lum value. Pure red, pure green, and pure blue, despite having the same saturation, do not share the same luminance. All the lum
function does is adjust their proportions so they are perceived similarly.
The non-separable blend modes defined in the W3C specification include pseudo-code helper functions for getting and setting the saturation and luminance of colors. Here are their implementations in Javascript.
// Convert back to RGB color space.
function clipColor(pix) {
const l = lum(pix);
const n = Math.min(pix[0], pix[1], pix[2]);
const x = Math.max(pix[0], pix[1], pix[2]);
const c = [pix[0], pix[1], pix[2], pix[3]];
if (n < 0)) {
c[0] = l + (((pix[0] - l) * l) / (l - n));
c[1] = l + (((pix[1] - l) * l) / (l - n));
c[2] = l + (((pix[2] - l) * l) / (l - n));
}
if (x > 1) {
c[0] = l + (((pix[0] - l) * (1 - l)) / (x - l));
c[1] = l + (((pix[1] - l) * (1 - l)) / (x - l));
c[2] = l + (((pix[2] - l) * (1 - l)) / (x - l));
}
return c;
}
// Calculate the luminance of a color.
function lum([red, green, blue]) {
return 0.3 * red + 0.59 * green + 0.11 * blue;
}
// Set the luminance of a color.
function setLum(pix, l) {
const d = l - lum(pix);
const c = [pix[0] + d, pix[1] + d, pix[2] + d, pix[3]];
return clipColor(c);
}
// Calculate the saturation of a color.
function sat([red, green, blue]) {
return Math.max(red, green, blue) - Math.min(red, green, blue);
}
// Set the saturation of a color.
function setSat(pix, s) {
// Copy the pixel.
const c = [pix[0], pix[1], pix[2], pix[3]];
// `mmm` gets the indices of the minimum, middle, and maximum values in the pixel.
// * Implementation can be seen in the Notes section
const [min, mid, max] = mmm(pix);
if (max > min) {
c[mid] = ((c[mid] - c[min]) * s) / (c[max] - c[min]);
c[max] = s;
} else {
c[mid] = 0;
c[max] = 0;
}
c[min] = 0;
return c;
}
And the implementation of the blend modes using these functions…
function hue(pix1, pix2) {
return setLum(setSat(pix2, sat(pix1)), lum(pix1));
}
function saturation(pix1, pix2) {
return setLum(setSat(pix1, sat(pix2)), lum(pix1));
}
function color(pix1, pix2) {
return setLum(pix2, lum(pix1));
}
function luminosity(pix1, pix2) {
return setLum(pix1, lum(pix2));
}
You can see that the helper functions do most of the work. The names of these modes indicate which component of the source color is going to be used. For example, saturation blend mode means that the resulting color will use the source color’s saturation and the hue and luminosity of the backdrop color. The Color mode is the outlier here, which uses the hue and saturation of the source color.
Hue mode uses the hue of the source color and the saturation and luminosity of the backdrop color.
Saturation mode uses the saturation of the source color and the hue and luminosity of the backdrop color.
Luminosity mode uses the luminosity of the source color and the hue and saturation of the backdrop color.
Color mode uses the hue and saturation of the source color and the luminosity of the backdrop color.
And that wraps up all the modes defined by the W3C. Now we’ll move on to some modes that you won’t find implemented in CSS.
Non-W3C spec modes
Lighter Color and Darker Color
These modes are similar to lighten and darken modes, but rather than operate on a channel level, they consider all the channels at once. This makes them non-separable blend modes. For lighter color, if the sum of red, green, and blue channels of the source pixel is greater the sum of the red, green, and blue channels the source color is used, otherwise the backdrop color is used. Darker color works in just the opposite manner.
function lighterColor(pix1, pix2) {
const total1 = pix1[0] + pix1[1] + pix1[2];
const total2 = pix2[0] + pix2[1] + pix2[2];
const blend = [pix2[0], pix2[1], pix2[2], pix2[3]];
if (total1 > total2) {
blend[0] = pix1[0];
blend[1] = pix1[1];
blend[2] = pix1[2];
}
return blend;
}
function darkerColor(pix1, pix2) {
const total1 = pix1[0] + pix1[1] + pix1[2];
const total2 = pix2[0] + pix2[1] + pix2[2];
const blend = [pix2[0], pix2[1], pix2[2], pix2[3]];
if (total1 < total2) {
blend[0] = pix1[0];
blend[1] = pix1[1];
blend[2] = pix1[2];
}
return blend;
}
What remains
Here are the remaining blend modes I implemented based on the description from the Adobe Photoshop User Guide. I doubt they are exactly the implementation in Photoshop, but I think they produce good results. The following descriptions I’ve taken right from that page. I’ve changed them to use the terms and values used throughout this article.
Vivid Light burns or dodges the colors by increasing or decreasing the contrast, depending on the source color. If the blend color is lighter than 50% gray, the pixel is lightened by decreasing the contrast. If the source color is darker than 50% gray, the pixel is darkened by increasing the contrast.
Linear Light burns or dodges the colors by decreasing or increasing the brightness, depending on the source color. If the source color is lighter than 50% gray, the image is lightened by increasing the brightness. If the source color is darker than 50% gray, the image is darkened by decreasing the brightness.
Pin Light replaces the colors, depending on the source color. If the source color is lighter than 50% gray, pixels darker than the source color are replaced, and pixels lighter than the source color do not change. If the source color is darker than 50% gray, pixels lighter than the source color are replaced, and pixels darker than the source color do not change. This is useful for adding special effects to an image.
Hard Mix adds the red, green and blue channel values of the source color to the RGB values of the backdrop color. If the resulting sum for a channel is 1 or greater, it receives a value of 1; if less than 1, a value of 0. Therefore, all blended pixels have red, green, and blue channel values of either 0 or 1. This changes all pixels to primary additive colors (red, green, or blue), and their combinations cyan, magenta, yellow, white, or black.
function pinLight(pix1, pix2) {
let red = pix1[0];
if (pix2[0] > 0.5) {
if (pix1[0] < pix2[0]) {
red = pix2[0];
}
} else {
if (pix1[0] > pix2[0]) {
red = pix2[0];
}
}
let green = pix1[1];
if (pix2[1] > 0.5) {
if (pix1[1] < pix2[1]) {
green = pix2[1];
}
} else {
if (pix1[1] > pix2[1]) {
green = pix2[1];
}
}
let blue = pix1[2];
if (pix2[2] > 0.5) {
if (pix1[2] < pix2[2]) {
blue = pix2[2];
}
} else {
if (pix1[2] > pix2[2]) {
blue = pix2[2];
}
}
return [red, green, blue, pix2[3]];
}
function vividLight(pix1, pix2) {
let red = 0;
if (pix2[0] > 0.5) { // dodge
red = Math.min(1, pix1[0] / (1 - pix2[0]))
} else { // burn
red = 1 - (1 - pix1[0]) / pix2[0];
}
let green = 0;
if (pix2[1] > 0.5) {
green = Math.min(1, pix1[1] / (1 - pix2[1]));
} else {
green = 1 - (1 - pix1[1]) / pix2[1];
}
let blue = 0;
if (pix2[2] > 0.5) {
blue = Math.min(1, pix1[2] / (1 - pix2[2]));
} else {
blue = 1 - (1 - pix1[2]) / pix2[2];
}
return [red, green, blue, pix2[3]];
}
function linearLight(pix1, pix2) {
let red = 0;
if (pix2[0] > 0.5) {
// linear dodge
red = pix1[0] + pix2[0];
} else {
// linear burn
red = pix1[0] + pix2[0] - 1;
}
let green = 0;
if (pix2[1] > 0.5) {
green = pix1[1] + pix2[1];
} else {
green = pix1[1] + pix2[1] - 1;
}
let blue = 0;
if (pix2[2] > 0.5) {
blue = pix1[2] + pix2[2];
} else {
blue = pix1[2] + pix2[2] - 1;
}
return [red, green, blue, pix2[3]];
}
function hardLight(pix1, pix2) {
let [r1, g1, b1] = pix1;
let [r2, g2, b2] = pix2;
let r = 0;
if (r2 < 0.5) {
r = r1 * r2 * 2;
} else {
r = 1 - 2 * (1 - r1) * (1 - r2);
}
let g = 0;
if (g2 < 0.5) {
g = g1 * g2 * 2;
} else {
g = 1 - 2 * (1 - g1) * (1 - g2);
}
let b = 0;
if (b2 < 0.5) {
b = b1 * b2 * 2;
} else {
b = 1 - 2 * (1 - b1) * (1 - b2);
}
return [r, g, b, pix2[3]];
}
Alpha Compositing
There is one more area of blending I want to touch on. In addition to red, green, and blue channels, pixels can also contain information about their opacity. This is called the alpha channel. Like the RGB channels, it also ranges from 0 to 1. A value of 1 means that the pixel is fully opaque and a value of 0 means that it is completely transparent. For all these blend modes we just ignored the alpha value, essentially treating it as 1, or opaque.
The alpha compositing formula is co = Cs * as + Cb * ab * (1 - as)
, where co
is the output Cs
is the source color, as
is the source alpha, Cb
is the backdrop color, and ab
is the backdrop alpha. This is applied to the red, green, and blue channels separately.
In the context of the blend modes, we compute the blend first, then use the alpha compositing formula to lay the blend over the backdrop. Let’s come back to the normal blend mode to see what this looks like.
// The alpha compositing formula
function calcAlpha(cb, alphaB, cs, alphaS) {
return cs * alphaS + cb * alphaB * (1 - alphaS);
}
// Apply the alpha compositing formula to each channel.
function applyAlpha(backdrop, source) {
return [
calcAlpha(backdrop[0], backdrop[3], source[0], source[3]),
calcAlpha(backdrop[1], backdrop[3], source[1], source[3]),
calcAlpha(backdrop[2], backdrop[3], source[2], source[3]),
];
}
function normal(backdrop, source) {
const [red, green, blue] = source;
return applyAlpha(pix1, blend);
}
The demo application allows testing this out by adjusting the alpha value in the color selector.
Conclusion
Well, that’s all the modes I’ve implemented. I plan to do a lot more, but they will be of my own invention. You should make some of your own as well. If you have questions or want some help feel free to hit me up on twitter.
Thanks for reading!
Glossary
- Separable - A blend mode is considered to be separable if it is applied to each channel in the pixel.
- Commutative - A blend mode is commutative if it produces the same result if the source color and backdrop color change places.
- Burning - Darkroom technique of allowing photographic paper to receive more light, thus becoming darker.
- Dodging - Darkroom technique of blocking light from hitting the photographic paper, thus becoming lighter.
- Contrast - A measure of the difference in brightness between colors.
- Saturation - A measure of the amount of gray in a color. the Lower the saturation the grayer it is.
- Luminance - A measure of the brightness of a color.
Resources
Footnotes
[1] I just want to emphasize this is not the process of capturing a photo, but rather developing the already captured photo. During the capture of the photograph, light striking the film makes it get brighter. During the development, light striking the photographic paper makes it get darker. [2] Luminance has an objective measure as well, which is an actual measure of light waves. Luminance as we use it here can just be thought of as subjective brightness.
- Here is the implementation of the function I used for calculating the indices of the minimum, middle, and maximum components of a pixel for the non-separable blend modes.
function mmm(pix) {
let min = 0;
let mid = 0;
let max = 0;
if (pix[0] < pix[1] && pix[0] < pix[2]) {
min = 0;
if (pix[1] > pix[2]) {
max = 1;
mid = 2;
} else {
max = 2;
mid = 1;
}
} else if (pix[1] < pix[0] && pix[1] < pix[2]) {
min = 1;
if (pix[0] > pix[2]) {
max = 0;
mid = 2;
} else {
max = 2;
mid = 0;
}
} else {
min = 2;
if (pix[0] > pix[1]) {
max = 0;
mid = 1;
} else {
max = 1;
mid = 0;
}
}
return [min, mid, max];
}