What are we doing?
Messing with pixel colors in CSS can be tricky. I have recently been presented with a tricky challenge, which doesn't seem to have a straightforward solution, but rather, only a lot of partial answers, so I wanted to explore that a little.
In brief: Modify the colors of pixels of an <img>
tag or the background-color
of a div.
There are various CSS tricks that can be applied to get some of the way there:
mix-blend-mode
, which lets you control how the background interacts with the foreground through various blending rulesfilter
which lets you apply various transformation to an imagemask-image
which lets you mask one image using another.
In my instance, I have a grey-scale image with alpha, and I want to tint it with a dynamic color that comes in through some RGB value.
Previously, I only had to tint with 4 static colors, and that was doable through some hackery with CSS filter
:
.green {
filter: invert(58%) sepia(47%) saturate(5640%) hue-rotate(89deg) brightness(123%) contrast(125%);
}
.red {
filter: invert(20%) sepia(90%) saturate(6471%) hue-rotate(357deg) brightness(68%) contrast(114%);
}
.yellow {
filter: brightness(20%) saturate(100%) invert(88%) sepia(56%) saturate(1316%) hue-rotate(356deg) brightness(108%) contrast(106%);
}
.orange {
filter: invert(67%) sepia(93%) saturate(860%) hue-rotate(357deg) brightness(99%) contrast(106%);
}
You can see how that really won't fly when I need to do it with a dynamic RGB value. How would I possibly translate
#009DFF
into something that fits that syntax?
mix-blend-mode
couldn't do what I needed at all. filter
would be impossible to make dynamic. mask-image
almost
did what I needed, because I could fiddle with the alpha values to apply a tint mask, however, it would not respect
the alpha of the source image, and so the pixels that were supposed to be invisible would also be tinted, so I would
always end up with a coloured rectangle, which is not what I wanted 😣
So as it usually goes when CSS doesn't quite provide me with what I need, I have to turn to JavaScript
So, let's do this
I made a simple function that:
- targets and image on the page
- creates an empty canvas
- draws the image onto the canvas
- Loops through every pixel and calls a callback function on the pixel
The callback lets me apply transformations per pixel. Not super flexible, but good enough for my needs. The function ended up looking like this:
function tint(selector, imageMutator) {
// Get the image
const imgElement = document.querySelector(selector);
// Ensure the image has loaded
imgElement.addEventListener('load', function () {
// Create a new canvas
const canvas = document.createElement('canvas');
// Set the canvas dimensions to the image dimensions
canvas.width = imgElement.width;
canvas.height = imgElement.height;
// Get the 2D canvas context
const ctx = canvas.getContext('2d');
// Draw the image onto the canvas
ctx.drawImage(imgElement, 0, 0, imgElement.width, imgElement.height);
// Get the image data from the canvas
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// imgData.data is an array with [r,g,b,a,r,g,b,a,...]
for (let i = 0; i < imgData.data.length; i += 4) {
const r = imgData.data[i];
const g = imgData.data[i + 1];
const b = imgData.data[i + 2];
const a = imgData.data[i + 3];
// Here's where you manipulate the pixels
const updated = imageMutator(r, g, b, a);
imgData.data[i] = updated[0]; // red
imgData.data[i + 1] = updated[1]; // green
imgData.data[i + 2] = updated[2]; // blue
imgData.data[i + 3] = updated[3]; // alpha
}
// Put the manipulated data back on the canvas
ctx.putImageData(imgData, 0, 0);
// Append the canvas to body (or any other element)
imgElement.after(canvas);
imgElement.remove();
});
}
Let's take it for a spin. I found a simple "Sample" PNG with alpha online, and I'll be using that as a reference for pixel mutations:
First, let's invert the color of all pixels. Not a big deal; simply subtract the value from the max value.
tint("img[id='image-to-invert']", function(r, g, b, a) {
return [255 - r, 255 - g, 255 - b, a];
});
Alright great. Now, let's try finding colors that have full alpha and color them blue.
tint("img[id='image-to-alpha-blue']", function(r, g, b, a) {
if (a === 255) {
return [0, 0, 255, 255];
}
return [r, g, b, a];
});
Easy. Now, let's find all the invisible pixels and give them a light pink color, but leave all of the other pixels along
tint("img[id='image-to-alpha-pink']", function(r, g, b, a) {
if (a > 0) {
return [r, g, b, a];
}
return [200, 0, 0, 50];
});
Alright, let's see if we can get tinting working then. We will tint all pixels that are not completely transparent. Still, if they have some non-max alpha, we will retain that alpha level.
The tintColor
function below is a simple linear interpolation function (LERP),
and I'll use it to apply a green
tint with a magnitude of `0.35
tint("img[id='image-to-alpha-tint']", function(r, g, b, a) {
if (a === 0) {
return [r, g, b, a];
}
var tintValue = [0, 255, 0, 255];
return tintColor([r, g, b, a], tintValue, 0.35);
});
function tintColor(originalColor, tintColor, t) {
// Ensure t is in the range [0, 1]
t = Math.max(0, Math.min(1, t));
// Calculate tinted values for each channel
const rTinted = originalColor[0] + t * (tintColor[0] - originalColor[0]);
const gTinted = originalColor[1] + t * (tintColor[1] - originalColor[1]);
const bTinted = originalColor[2] + t * (tintColor[2] - originalColor[2]);
const aTinted = originalColor[3] + t * (tintColor[3] - originalColor[3]);
// Clamp values to the valid range [0, 255]
const clampedRTinted = Math.max(0, Math.min(255, rTinted));
const clampedGTinted = Math.max(0, Math.min(255, gTinted));
const clampedBTinted = Math.max(0, Math.min(255, bTinted));
const clampedATinted = Math.max(0, Math.min(255, aTinted));
// Return the tinted color as an array
return [
Math.round(clampedRTinted),
Math.round(clampedGTinted),
Math.round(clampedBTinted),
Math.round(clampedATinted)
];
}
How pretty is that? 🥳
In conclusion
I would love to be able to just use CSS for this kind of thing, but as far as I've been able to gather, it is not possible.
For the time being, JavaScript seems to do what I need, and it provides a flexible entrypoint to do more interesting things with images.