240

My question is: given a target RGB color, what is the formula to recolor black (#000) into that color using only CSS filters?

For an answer to be accepted, it would need to provide a function (in any language) that would accept the target color as an argument and return the corresponding CSS filter string.

The context for this is the need to recolor an SVG inside a background-image. In this case, it is to support certain TeX math features in KaTeX: https://github.com/Khan/KaTeX/issues/587.

Example

If the target color is #ffff00 (yellow), one correct solution is:

filter: invert(100%) sepia() saturate(10000%) hue-rotate(0deg)

(demo)

Non-goals

  • Animation.
  • Non CSS-filter solutions.
  • Starting from a color other than black.
  • Caring about what happens to colors other than black.

Results so far

You can still get an Accepted answer by submitting a non brute-force solution!

Resources

  • How hue-rotate and sepia are calculated: https://stackoverflow.com/a/29521147/181228 Example Ruby implementation:

    LUM_R = 0.2126; LUM_G = 0.7152; LUM_B = 0.0722
    HUE_R = 0.1430; HUE_G = 0.1400; HUE_B = 0.2830
    
    def clamp(num)
      [0, [255, num].min].max.round
    end
    
    def hue_rotate(r, g, b, angle)
      angle = (angle % 360 + 360) % 360
      cos = Math.cos(angle * Math::PI / 180)
      sin = Math.sin(angle * Math::PI / 180)
      [clamp(
         r * ( LUM_R  +  (1 - LUM_R) * cos  -  LUM_R * sin       ) +
         g * ( LUM_G  -  LUM_G * cos        -  LUM_G * sin       ) +
         b * ( LUM_B  -  LUM_B * cos        +  (1 - LUM_B) * sin )),
       clamp(
         r * ( LUM_R  -  LUM_R * cos        +  HUE_R * sin       ) +
         g * ( LUM_G  +  (1 - LUM_G) * cos  +  HUE_G * sin       ) +
         b * ( LUM_B  -  LUM_B * cos        -  HUE_B * sin       )),
       clamp(
         r * ( LUM_R  -  LUM_R * cos        -  (1 - LUM_R) * sin ) +
         g * ( LUM_G  -  LUM_G * cos        +  LUM_G * sin       ) +
         b * ( LUM_B  +  (1 - LUM_B) * cos  +  LUM_B * sin       ))]
    end
    
    def sepia(r, g, b)
      [r * 0.393 + g * 0.769 + b * 0.189,
       r * 0.349 + g * 0.686 + b * 0.168,
       r * 0.272 + g * 0.534 + b * 0.131]
    end
    

    Note that the clamp above makes the hue-rotate function non-linear.

    Browser implementations: Chromium, Firefox.

  • Demo: Getting to a non-grayscale color from a grayscale color: https://stackoverflow.com/a/25524145/181228

  • A formula that almost works (from a similar question):
    https://stackoverflow.com/a/29958459/181228

    A detailed explanation of why the formula above is wrong (CSS hue-rotate is not a true hue rotation but a linear approximation):
    https://stackoverflow.com/a/19325417/2441511

21
  • So you want to LERP #000000 to #RRGGBB ? (Just clarifying)
    – Zze
    Commented Mar 23, 2017 at 3:31
  • 2
    Yeah sweet - just clarifying that you didn't want to incorporate a transition into the solution.
    – Zze
    Commented Mar 23, 2017 at 3:52
  • 2
    May be a blend mode would work for you ? You can easily convert black to any color ... But I don't get the global picture of what you want to achieve
    – vals
    Commented May 13, 2017 at 20:13
  • 1
    @glebm so you need to find a formula (using any method) to turn black into any color and apply it using css ?
    – ProllyGeek
    Commented May 14, 2017 at 1:12
  • 3
    @ProllyGeek Yes. One other constraint I should mention is that the resulting formula cannot be a brute force lookup of a 5GiB table (it should be usable from e.g. javascript on a webpage).
    – glebm
    Commented May 14, 2017 at 1:15

13 Answers 13

438
+500

@Dave was the first to post an answer to this (with working code), and his answer has been an invaluable source of shameless copy and pasting inspiration to me. This post began as an attempt to explain and refine @Dave's answer, but it has since evolved into an answer of its own.

My method is significantly faster. According to a jsPerf benchmark on randomly generated RGB colors, @Dave's algorithm runs in 600 ms, while mine runs in 30 ms. This can definitely matter, for instance in load time, where speed is critical.

Furthermore, for some colors, my algorithm performs better:

  • For rgb(0,255,0), @Dave's produces rgb(29,218,34) and mine produces rgb(1,255,0)
  • For rgb(0,0,255), @Dave's produces rgb(37,39,255) and mine produces rgb(5,6,255)
  • For rgb(19,11,118), @Dave's produces rgb(36,27,102) and mine produces rgb(20,11,112)

Demo

"use strict";

class Color {
    constructor(r, g, b) { this.set(r, g, b); }
    toString() { return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(this.b)})`; }

    set(r, g, b) {
        this.r = this.clamp(r);
        this.g = this.clamp(g);
        this.b = this.clamp(b);
    }

    hueRotate(angle = 0) {
        angle = angle / 180 * Math.PI;
        let sin = Math.sin(angle);
        let cos = Math.cos(angle);

        this.multiply([
            0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928,
            0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.140, 0.072 - cos * 0.072 - sin * 0.283,
            0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072
        ]);
    }

    grayscale(value = 1) {
        this.multiply([
            0.2126 + 0.7874 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 - 0.0722 * (1 - value),
            0.2126 - 0.2126 * (1 - value), 0.7152 + 0.2848 * (1 - value), 0.0722 - 0.0722 * (1 - value),
            0.2126 - 0.2126 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 + 0.9278 * (1 - value)
        ]);
    }

    sepia(value = 1) {
        this.multiply([
            0.393 + 0.607 * (1 - value), 0.769 - 0.769 * (1 - value), 0.189 - 0.189 * (1 - value),
            0.349 - 0.349 * (1 - value), 0.686 + 0.314 * (1 - value), 0.168 - 0.168 * (1 - value),
            0.272 - 0.272 * (1 - value), 0.534 - 0.534 * (1 - value), 0.131 + 0.869 * (1 - value)
        ]);
    }

    saturate(value = 1) {
        this.multiply([
            0.213 + 0.787 * value, 0.715 - 0.715 * value, 0.072 - 0.072 * value,
            0.213 - 0.213 * value, 0.715 + 0.285 * value, 0.072 - 0.072 * value,
            0.213 - 0.213 * value, 0.715 - 0.715 * value, 0.072 + 0.928 * value
        ]);
    }

    multiply(matrix) {
        let newR = this.clamp(this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]);
        let newG = this.clamp(this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]);
        let newB = this.clamp(this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]);
        this.r = newR; this.g = newG; this.b = newB;
    }

    brightness(value = 1) { this.linear(value); }
    contrast(value = 1) { this.linear(value, -(0.5 * value) + 0.5); }

    linear(slope = 1, intercept = 0) {
        this.r = this.clamp(this.r * slope + intercept * 255);
        this.g = this.clamp(this.g * slope + intercept * 255);
        this.b = this.clamp(this.b * slope + intercept * 255);
    }

    invert(value = 1) {
        this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
        this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
        this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
    }

    hsl() { // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
        let r = this.r / 255;
        let g = this.g / 255;
        let b = this.b / 255;
        let max = Math.max(r, g, b);
        let min = Math.min(r, g, b);
        let h, s, l = (max + min) / 2;

        if(max === min) {
            h = s = 0;
        } else {
            let d = max - min;
            s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
            switch(max) {
                case r: h = (g - b) / d + (g < b ? 6 : 0); break;
                case g: h = (b - r) / d + 2; break;
                case b: h = (r - g) / d + 4; break;
            } h /= 6;
        }

        return {
            h: h * 100,
            s: s * 100,
            l: l * 100
        };
    }

    clamp(value) {
        if(value > 255) { value = 255; }
        else if(value < 0) { value = 0; }
        return value;
    }
}

class Solver {
    constructor(target) {
        this.target = target;
        this.targetHSL = target.hsl();
        this.reusedColor = new Color(0, 0, 0); // Object pool
    }

    solve() {
        let result = this.solveNarrow(this.solveWide());
        return {
            values: result.values,
            loss: result.loss,
            filter: this.css(result.values)
        };
    }

    solveWide() {
        const A = 5;
        const c = 15;
        const a = [60, 180, 18000, 600, 1.2, 1.2];

        let best = { loss: Infinity };
        for(let i = 0; best.loss > 25 && i < 3; i++) {
            let initial = [50, 20, 3750, 50, 100, 100];
            let result = this.spsa(A, a, c, initial, 1000);
            if(result.loss < best.loss) { best = result; }
        } return best;
    }

    solveNarrow(wide) {
        const A = wide.loss;
        const c = 2;
        const A1 = A + 1;
        const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
        return this.spsa(A, a, c, wide.values, 500);
    }

    spsa(A, a, c, values, iters) {
        const alpha = 1;
        const gamma = 0.16666666666666666;

        let best = null;
        let bestLoss = Infinity;
        let deltas = new Array(6);
        let highArgs = new Array(6);
        let lowArgs = new Array(6);

        for(let k = 0; k < iters; k++) {
            let ck = c / Math.pow(k + 1, gamma);
            for(let i = 0; i < 6; i++) {
                deltas[i] = Math.random() > 0.5 ? 1 : -1;
                highArgs[i] = values[i] + ck * deltas[i];
                lowArgs[i]  = values[i] - ck * deltas[i];
            }

            let lossDiff = this.loss(highArgs) - this.loss(lowArgs);
            for(let i = 0; i < 6; i++) {
                let g = lossDiff / (2 * ck) * deltas[i];
                let ak = a[i] / Math.pow(A + k + 1, alpha);
                values[i] = fix(values[i] - ak * g, i);
            }

            let loss = this.loss(values);
            if(loss < bestLoss) { best = values.slice(0); bestLoss = loss; }
        } return { values: best, loss: bestLoss };

        function fix(value, idx) {
            let max = 100;
            if(idx === 2 /* saturate */) { max = 7500; }
            else if(idx === 4 /* brightness */ || idx === 5 /* contrast */) { max = 200; }

            if(idx === 3 /* hue-rotate */) {
                if(value > max) { value = value % max; }
                else if(value < 0) { value = max + value % max; }
            } else if(value < 0) { value = 0; }
            else if(value > max) { value = max; }
            return value;
        }
    }

    loss(filters) { // Argument is array of percentages.
        let color = this.reusedColor;
        color.set(0, 0, 0);

        color.invert(filters[0] / 100);
        color.sepia(filters[1] / 100);
        color.saturate(filters[2] / 100);
        color.hueRotate(filters[3] * 3.6);
        color.brightness(filters[4] / 100);
        color.contrast(filters[5] / 100);

        let colorHSL = color.hsl();
        return Math.abs(color.r - this.target.r)
            + Math.abs(color.g - this.target.g)
            + Math.abs(color.b - this.target.b)
            + Math.abs(colorHSL.h - this.targetHSL.h)
            + Math.abs(colorHSL.s - this.targetHSL.s)
            + Math.abs(colorHSL.l - this.targetHSL.l);
    }

    css(filters) {
        function fmt(idx, multiplier = 1) { return Math.round(filters[idx] * multiplier); }
        return `filter: invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(2)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(5)}%);`;
    }
}

$("button.execute").click(() => {
    let rgb = $("input.target").val().split(",");
    if (rgb.length !== 3) { alert("Invalid format!"); return; }

    let color = new Color(rgb[0], rgb[1], rgb[2]);
    let solver = new Solver(color);
    let result = solver.solve();

    let lossMsg;
    if (result.loss < 1) {
        lossMsg = "This is a perfect result.";
    } else if (result.loss < 5) {
        lossMsg = "The is close enough.";
    } else if(result.loss < 15) {
        lossMsg = "The color is somewhat off. Consider running it again.";
    } else {
        lossMsg = "The color is extremely off. Run it again!";
    }

    $(".realPixel").css("background-color", color.toString());
    $(".filterPixel").attr("style", result.filter);
    $(".filterDetail").text(result.filter);
    $(".lossDetail").html(`Loss: ${result.loss.toFixed(1)}. <b>${lossMsg}</b>`);
});
.pixel {
    display: inline-block;
    background-color: #000;
    width: 50px;
    height: 50px;
}

.filterDetail {
    font-family: "Consolas", "Menlo", "Ubuntu Mono", monospace;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>

<input class="target" type="text" placeholder="r, g, b" value="250, 150, 50" />
<button class="execute">Compute Filters</button>

<p>Real pixel, color applied through CSS <code>background-color</code>:</p>
<div class="pixel realPixel"></div>

<p>Filtered pixel, color applied through CSS <code>filter</code>:</p>
<div class="pixel filterPixel"></div>

<p class="filterDetail"></p>
<p class="lossDetail"></p>


Usage

let color = new Color(0, 255, 0);
let solver = new Solver(color);
let result = solver.solve();
let filterCSS = result.filter;

Explanation

We'll begin with some Javascript.

"use strict";

class Color {
    constructor(r, g, b) {
        this.r = this.clamp(r);
        this.g = this.clamp(g);
        this.b = this.clamp(b);
    } toString() { return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(this.b)})`; }

    hsl() { // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
        let r = this.r / 255;
        let g = this.g / 255;
        let b = this.b / 255;
        let max = Math.max(r, g, b);
        let min = Math.min(r, g, b);
        let h, s, l = (max + min) / 2;

        if(max === min) {
            h = s = 0;
        } else {
            let d = max - min;
            s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
            switch(max) {
                case r: h = (g - b) / d + (g < b ? 6 : 0); break;
                case g: h = (b - r) / d + 2; break;
                case b: h = (r - g) / d + 4; break;
            } h /= 6;
        }

        return {
            h: h * 100,
            s: s * 100,
            l: l * 100
        };
    }

    clamp(value) {
        if(value > 255) { value = 255; }
        else if(value < 0) { value = 0; }
        return value;
    }
}

class Solver {
    constructor(target) {
        this.target = target;
        this.targetHSL = target.hsl();
    }

    css(filters) {
        function fmt(idx, multiplier = 1) { return Math.round(filters[idx] * multiplier); }
        return `filter: invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(2)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(5)}%);`;
    }
}

Explanation:

  • The Color class represents a RGB color.
    • Its toString() function returns the color in a CSS rgb(...) color string.
    • Its hsl() function returns the color, converted to HSL.
    • Its clamp() function ensures that a given color value is within bounds (0-255).
  • The Solver class will attempt to solve for a target color.
    • Its css() function returns a given filter in a CSS filter string.

Implementing grayscale(), sepia(), and saturate()

The heart of CSS/SVG filters are filter primitives, which represent low-level modifications to an image.

The filters grayscale(), sepia(), and saturate() are implemented by the filter primative <feColorMatrix>, which performs matrix multiplication between a matrix specified by the filter (often dynamically generated), and a matrix created from the color. Diagram:

Matrix multiplication

There are some optimizations we can make here:

  • The last element of the color matrix is and will always be 1. There is no point of calculating or storing it.
  • There is no point of calculating or storing the alpha/transparency value (A) either, since we are dealing with RGB, not RGBA.
  • Therefore, we can trim the filter matrices from 5x5 to 3x5, and the color matrix from 1x5 to 1x3. This saves a bit of work.
  • All <feColorMatrix> filters leave columns 4 and 5 as zeroes. Therefore, we can further reduce the filter matrix to 3x3.
  • Since the multiplication is relatively simple, there is no need to drag in complex math libraries for this. We can implement the matrix multiplication algorithm ourselves.

Implementation:

function multiply(matrix) {
    let newR = this.clamp(this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]);
    let newG = this.clamp(this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]);
    let newB = this.clamp(this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]);
    this.r = newR; this.g = newG; this.b = newB;
}

(We use temporary variables to hold the results of each row multiplication, because we do not want changes to this.r, etc. affecting subsequent calculations.)

Now that we have implemented <feColorMatrix>, we can implement grayscale(), sepia(), and saturate(), which simply invoke it with a given filter matrix:

function grayscale(value = 1) {
    this.multiply([
        0.2126 + 0.7874 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 - 0.0722 * (1 - value),
        0.2126 - 0.2126 * (1 - value), 0.7152 + 0.2848 * (1 - value), 0.0722 - 0.0722 * (1 - value),
        0.2126 - 0.2126 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 + 0.9278 * (1 - value)
    ]);
}

function sepia(value = 1) {
    this.multiply([
        0.393 + 0.607 * (1 - value), 0.769 - 0.769 * (1 - value), 0.189 - 0.189 * (1 - value),
        0.349 - 0.349 * (1 - value), 0.686 + 0.314 * (1 - value), 0.168 - 0.168 * (1 - value),
        0.272 - 0.272 * (1 - value), 0.534 - 0.534 * (1 - value), 0.131 + 0.869 * (1 - value)
    ]);
}

function saturate(value = 1) {
    this.multiply([
        0.213 + 0.787 * value, 0.715 - 0.715 * value, 0.072 - 0.072 * value,
        0.213 - 0.213 * value, 0.715 + 0.285 * value, 0.072 - 0.072 * value,
        0.213 - 0.213 * value, 0.715 - 0.715 * value, 0.072 + 0.928 * value
    ]);
}

Implementing hue-rotate()

The hue-rotate() filter is implemented by <feColorMatrix type="hueRotate" />.

The filter matrix is calculated as shown below:

For instance, element a00 would be calculated like so:

Notes:

  • The angle of rotation is given in degrees, which must be converted to radians before passed to Math.sin() or Math.cos().
  • Math.sin(angle) and Math.cos(angle) should be computed once and then cached.

Implementation:

function hueRotate(angle = 0) {
    angle = angle / 180 * Math.PI;
    let sin = Math.sin(angle);
    let cos = Math.cos(angle);

    this.multiply([
        0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928,
        0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.140, 0.072 - cos * 0.072 - sin * 0.283,
        0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072
    ]);
}

Implementing brightness() and contrast()

The brightness() and contrast() filters are implemented by <feComponentTransfer> with <feFuncX type="linear" />.

Each <feFuncX type="linear" /> element accepts a slope and intercept attribute. It then calculates each new color value through a simple formula:

value = slope * value + intercept

This is easy to implement:

function linear(slope = 1, intercept = 0) {
    this.r = this.clamp(this.r * slope + intercept * 255);
    this.g = this.clamp(this.g * slope + intercept * 255);
    this.b = this.clamp(this.b * slope + intercept * 255);
}

Once this is implemented, brightness() and contrast() can be implemented as well:

function brightness(value = 1) { this.linear(value); }
function contrast(value = 1) { this.linear(value, -(0.5 * value) + 0.5); }

Implementing invert()

The invert() filter is implemented by <feComponentTransfer> with <feFuncX type="table" />.

The spec states:

In the following, C is the initial component and C' is the remapped component; both in the closed interval [0,1].

For "table", the function is defined by linear interpolation between values given in the attribute tableValues. The table has n + 1 values (i.e., v0 to vn) specifying the start and end values for n evenly sized interpolation regions. Interpolations use the following formula:

For a value C find k such that:

k / n ≤ C < (k + 1) / n

The result C' is given by:

C' = vk + (C - k / n) * n * (vk+1 - vk)

An explanation of this formula:

  • The invert() filter defines this table: [value, 1 - value]. This is tableValues or v.
  • The formula defines n, such that n + 1 is the table's length. Since the table's length is 2, n = 1.
  • The formula defines k, with k and k + 1 being indexes of the table. Since the table has 2 elements, k = 0.

Thus, we can simplify the formula to:

C' = v0 + C * (v1 - v0)

Inlining the table's values, we are left with:

C' = value + C * (1 - value - value)

One more simplification:

C' = value + C * (1 - 2 * value)

The spec defines C and C' to be RGB values, within the bounds 0-1 (as opposed to 0-255). As a result, we must scale down the values before computation, and scale them back up after.

Thus we arrive at our implementation:

function invert(value = 1) {
    this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
    this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
    this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
}

Interlude: @Dave's brute-force algorithm

@Dave's code generates 176,660 filter combinations, including:

  • 11 invert() filters (0%, 10%, 20%, ..., 100%)
  • 11 sepia() filters (0%, 10%, 20%, ..., 100%)
  • 20 saturate() filters (5%, 10%, 15%, ..., 100%)
  • 73 hue-rotate() filters (0deg, 5deg, 10deg, ..., 360deg)

It calculates filters in the following order:

filter: invert(a%) sepia(b%) saturate(c%) hue-rotate(θdeg);

It then iterates through all computed colors. It stops once it has found a generated color within tolerance (all RGB values are within 5 units from the target color).

However, this is slow and inefficient. Thus, I present my own answer.

Implementing SPSA

First, we must define a loss function, that returns the difference between the color produced by a filter combination, and the target color. If the filters are perfect, the loss function should return 0.

We will measure color difference as the sum of two metrics:

  • RGB difference, because the goal is to produce the closest RGB value.
  • HSL difference, because many HSL values correspond to filters (e.g. hue roughly correlates with hue-rotate(), saturation correlates with saturate(), etc.) This guides the algorithm.

The loss function will take one argument – an array of filter percentages.

We will use the following filter order:

filter: invert(a%) sepia(b%) saturate(c%) hue-rotate(θdeg) brightness(e%) contrast(f%);

Implementation:

function loss(filters) {
    let color = new Color(0, 0, 0);
    color.invert(filters[0] / 100);
    color.sepia(filters[1] / 100);
    color.saturate(filters[2] / 100);
    color.hueRotate(filters[3] * 3.6);
    color.brightness(filters[4] / 100);
    color.contrast(filters[5] / 100);

    let colorHSL = color.hsl();
    return Math.abs(color.r - this.target.r)
        + Math.abs(color.g - this.target.g)
        + Math.abs(color.b - this.target.b)
        + Math.abs(colorHSL.h - this.targetHSL.h)
        + Math.abs(colorHSL.s - this.targetHSL.s)
        + Math.abs(colorHSL.l - this.targetHSL.l);
}

We will try to minimize the loss function, such that:

loss([a, b, c, d, e, f]) = 0

The SPSA algorithm (website, more info, paper, implementation paper, reference code) is very good at this. It was designed to optimize complex systems with local minima, noisy/nonlinear/ multivariate loss functions, etc. It has been used to tune chess engines. And unlike many other algorithms, the papers describing it are actually comprehensible (albeit with great effort).

Implementation:

function spsa(A, a, c, values, iters) {
    const alpha = 1;
    const gamma = 0.16666666666666666;

    let best = null;
    let bestLoss = Infinity;
    let deltas = new Array(6);
    let highArgs = new Array(6);
    let lowArgs = new Array(6);

    for(let k = 0; k < iters; k++) {
        let ck = c / Math.pow(k + 1, gamma);
        for(let i = 0; i < 6; i++) {
            deltas[i] = Math.random() > 0.5 ? 1 : -1;
            highArgs[i] = values[i] + ck * deltas[i];
            lowArgs[i]  = values[i] - ck * deltas[i];
        }

        let lossDiff = this.loss(highArgs) - this.loss(lowArgs);
        for(let i = 0; i < 6; i++) {
            let g = lossDiff / (2 * ck) * deltas[i];
            let ak = a[i] / Math.pow(A + k + 1, alpha);
            values[i] = fix(values[i] - ak * g, i);
        }

        let loss = this.loss(values);
        if(loss < bestLoss) { best = values.slice(0); bestLoss = loss; }
    } return { values: best, loss: bestLoss };

    function fix(value, idx) {
        let max = 100;
        if(idx === 2 /* saturate */) { max = 7500; }
        else if(idx === 4 /* brightness */ || idx === 5 /* contrast */) { max = 200; }

        if(idx === 3 /* hue-rotate */) {
            if(value > max) { value = value % max; }
            else if(value < 0) { value = max + value % max; }
        } else if(value < 0) { value = 0; }
        else if(value > max) { value = max; }
        return value;
    }
}

I made some modifications/optimizations to SPSA:

  • Using the best result produced, instead of the last.
  • Reusing all arrays (deltas, highArgs, lowArgs), instead of recreating them with each iteration.
  • Using an array of values for a, instead of a single value. This is because all of the filters are different, and thus they should move/converge at different speeds.
  • Running a fix function after each iteration. It clamps all values to between 0% and 100%, except saturate (where the maximum is 7500%), brightness and contrast (where the maximum is 200%), and hueRotate (where the values are wrapped around instead of clamped).

I use SPSA in a two-stage process:

  1. The "wide" stage, that tries to "explore" the search space. It will make limited retries of SPSA if the results are not satisfactory.
  2. The "narrow" stage, that takes the best result from the wide stage and attempts to "refine" it. It uses dynamic values for A and a.

Implementation:

function solve() {
    let result = this.solveNarrow(this.solveWide());
    return {
        values: result.values,
        loss: result.loss,
        filter: this.css(result.values)
    };
}

function solveWide() {
    const A = 5;
    const c = 15;
    const a = [60, 180, 18000, 600, 1.2, 1.2];

    let best = { loss: Infinity };
    for(let i = 0; best.loss > 25 && i < 3; i++) {
        let initial = [50, 20, 3750, 50, 100, 100];
        let result = this.spsa(A, a, c, initial, 1000);
        if(result.loss < best.loss) { best = result; }
    } return best;
}

function solveNarrow(wide) {
    const A = wide.loss;
    const c = 2;
    const A1 = A + 1;
    const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
    return this.spsa(A, a, c, wide.values, 500);
}

Tuning SPSA

Warning: Do not mess with the SPSA code, especially with its constants, unless you are sure you know what you are doing.

The important constants are A, a, c, the initial values, the retry thresholds, the values of max in fix(), and the number of iterations of each stage. All of these values were carefully tuned to produce good results, and randomly screwing with them will almost definitely reduce the usefulness of the algorithm.

If you insist on altering it, you must measure before you "optimize".

First, apply this patch.

Then run the code in Node.js. After quite some time, the result should be something like this:

Average loss: 3.4768521401985275
Average time: 11.4915ms

Now tune the constants to your heart's content.

Some tips:

  • The average loss should be around 4. If it is greater than 4, it is producing results that are too far off, and you should tune for accuracy. If it is less than 4, it is wasting time, and you should reduce the number of iterations.
  • If you increase/decrease the number of iterations, adjust A appropriately.
  • If you increase/decrease A, adjust a appropriately.
  • Use the --debug flag if you want to see the result of each iteration.

TL;DR

18
  • 6
    Very nice summary of the development process! Are you reading my thoughts?!
    – Dave
    Commented May 14, 2017 at 14:00
  • 28
    Great Answer! Implementation in this codepen
    – KyleMit
    Commented Dec 3, 2018 at 21:46
  • 12
    This is a completely insane method. You can set a color directly using a SVG filter (fifth column in a feColorMatrix) and you can reference that filter from CSS - why wouldn't you use that method? Commented Dec 30, 2018 at 2:00
  • 10
    @MichaelMullany Well, that's embarrassing for me, considering how long I worked on this. I didn't think of your method, but now I understand – to recolor an element to any arbitrary color, you just dynamically generate a SVG with a <filter> containing a <feColorMatrix> with the proper values (all zeroes except the last column, which contains the target RGB values, 0, and 1), insert the SVG into the DOM, and reference the filter from CSS. Please write up your solution as an answer (with a demo), and I'll upvote. Commented Dec 30, 2018 at 4:54
  • 3
    By the way, from today's perspective this answer is almost four years old and I find I am no longer proud or satisfied with my existing solution. As a result, I am planning a complete rewrite of this answer, featuring a npm package, a pretty website, a more efficient numerical optimization algorithm than SPSA, better explanations and visualizations, etc., and yes, the ability to set the initial color. Hopefully we'll see it in the next year. Commented Dec 18, 2020 at 7:35
84
+250

This was quite a trip down the rabbit hole but here it is!

var tolerance = 1;
var invertRange = [0, 1];
var invertStep = 0.1;
var sepiaRange = [0, 1];
var sepiaStep = 0.1;
var saturateRange = [5, 100];
var saturateStep = 5;
var hueRotateRange = [0, 360];
var hueRotateStep = 5;
var possibleColors;
var color = document.getElementById('color');
var pixel = document.getElementById('pixel');
var filtersBox = document.getElementById('filters');
var button = document.getElementById('button');
button.addEventListener('click', function() { 			      
	getNewColor(color.value);
})

// matrices taken from https://www.w3.org/TR/filter-effects/#feColorMatrixElement
function sepiaMatrix(s) {
	return [
		(0.393 + 0.607 * (1 - s)), (0.769 - 0.769 * (1 - s)), (0.189 - 0.189 * (1 - s)),
		(0.349 - 0.349 * (1 - s)), (0.686 + 0.314 * (1 - s)), (0.168 - 0.168 * (1 - s)),
		(0.272 - 0.272 * (1 - s)), (0.534 - 0.534 * (1 - s)), (0.131 + 0.869 * (1 - s)),
	]
}

function saturateMatrix(s) {
	return [
		0.213+0.787*s, 0.715-0.715*s, 0.072-0.072*s,
		0.213-0.213*s, 0.715+0.285*s, 0.072-0.072*s,
		0.213-0.213*s, 0.715-0.715*s, 0.072+0.928*s,
	]
}

function hueRotateMatrix(d) {
	var cos = Math.cos(d * Math.PI / 180);
	var sin = Math.sin(d * Math.PI / 180);
	var a00 = 0.213 + cos*0.787 - sin*0.213;
	var a01 = 0.715 - cos*0.715 - sin*0.715;
	var a02 = 0.072 - cos*0.072 + sin*0.928;

	var a10 = 0.213 - cos*0.213 + sin*0.143;
	var a11 = 0.715 + cos*0.285 + sin*0.140;
	var a12 = 0.072 - cos*0.072 - sin*0.283;

	var a20 = 0.213 - cos*0.213 - sin*0.787;
	var a21 = 0.715 - cos*0.715 + sin*0.715;
	var a22 = 0.072 + cos*0.928 + sin*0.072;

	return [
		a00, a01, a02,
		a10, a11, a12,
		a20, a21, a22,
	]
}

function clamp(value) {
	return value > 255 ? 255 : value < 0 ? 0 : value;
}

function filter(m, c) {
	return [
		clamp(m[0]*c[0] + m[1]*c[1] + m[2]*c[2]),
		clamp(m[3]*c[0] + m[4]*c[1] + m[5]*c[2]),
		clamp(m[6]*c[0] + m[7]*c[1] + m[8]*c[2]),
	]
}

function invertBlack(i) {
	return [
		i * 255,
		i * 255,
		i * 255,
	]
}

function generateColors() {
	let possibleColors = [];

	let invert = invertRange[0];
	for (invert; invert <= invertRange[1]; invert+=invertStep) {
		let sepia = sepiaRange[0];
		for (sepia; sepia <= sepiaRange[1]; sepia+=sepiaStep) {
			let saturate = saturateRange[0];
			for (saturate; saturate <= saturateRange[1]; saturate+=saturateStep) {
				let hueRotate = hueRotateRange[0];
				for (hueRotate; hueRotate <= hueRotateRange[1]; hueRotate+=hueRotateStep) {
					let invertColor = invertBlack(invert);
					let sepiaColor = filter(sepiaMatrix(sepia), invertColor);
					let saturateColor = filter(saturateMatrix(saturate), sepiaColor);
					let hueRotateColor = filter(hueRotateMatrix(hueRotate), saturateColor);

					let colorObject = {
						filters: { invert, sepia, saturate, hueRotate },
						color: hueRotateColor
					}

					possibleColors.push(colorObject);
				}
			}
		}
	}

	return possibleColors;
}

function getFilters(targetColor, localTolerance) {
	possibleColors = possibleColors || generateColors();

	for (var i = 0; i < possibleColors.length; i++) {
		var color = possibleColors[i].color;
		if (
			Math.abs(color[0] - targetColor[0]) < localTolerance &&
			Math.abs(color[1] - targetColor[1]) < localTolerance &&
			Math.abs(color[2] - targetColor[2]) < localTolerance
		) {
			return filters = possibleColors[i].filters;
			break;
		}
	}

	localTolerance += tolerance;
	return getFilters(targetColor, localTolerance)
}

function getNewColor(color) {
	var targetColor = color.split(',');
	targetColor = [
	    parseInt(targetColor[0]), // [R]
	    parseInt(targetColor[1]), // [G]
	    parseInt(targetColor[2]), // [B]
    ]
    var filters = getFilters(targetColor, tolerance);
    var filtersCSS = 'filter: ' +
	    'invert('+Math.floor(filters.invert*100)+'%) '+
	    'sepia('+Math.floor(filters.sepia*100)+'%) ' +
	    'saturate('+Math.floor(filters.saturate*100)+'%) ' +
	    'hue-rotate('+Math.floor(filters.hueRotate)+'deg);';
    pixel.style = filtersCSS;
    filtersBox.innerText = filtersCSS
}

getNewColor(color.value);
#pixel {
  width: 50px;
  height: 50px;
  background: rgb(0,0,0);
}
<input type="text" id="color" placeholder="R,G,B" value="250,150,50" />
<button id="button">get filters</button>
<div id="pixel"></div>
<div id="filters"></div>

EDIT: This solution is not intended for production use and only illustrates an approach that can be taken to achieve what OP is asking for. As is, it is weak in some areas of the color spectrum. Better results can be achieved by more granularity in the step iterations or by implementing more filter functions for reasons described in detail in @MultiplyByZer0's answer.

EDIT2: OP is looking for a non brute force solution. In that case it's pretty simple, just solve this equation:

CSS Filter Matrix Equations

where

a = hue-rotation
b = saturation
c = sepia
d = invert
12
  • If I put in 255,0,255, my digital color meter reports the outcome as #d619d9 rather than #ff00ff.
    – Siguza
    Commented May 14, 2017 at 2:40
  • @Siguza It's definitely not perfect, edge case colors can be tweaked by adjusting the boundaries in the loops.
    – Dave
    Commented May 14, 2017 at 3:29
  • 7
    That equation is anything but "pretty simple" Commented May 16, 2017 at 9:21
  • I think the equation above is also missing clamp?
    – glebm
    Commented May 16, 2017 at 10:45
  • 1
    Clamp has no place in there. And from what I remember from my college math, these equations are computed by numerical calculations aka "brute force" so good luck!
    – Dave
    Commented May 16, 2017 at 14:59
36

Note : OP asked me to undelete, but the bounty shall go to Dave's answer.


I know it's not what was asked in the body of the question, and certainly not what we were all waiting for, but there is one CSS filter which does exactly this : drop-shadow()

Caveats :

  • The shadow is drawn behind the existing content. This means we have to make some absolute positioning tricks.
  • All pixels will be treated the same, but OP said [we should not be ] "Caring about what happens to colors other than black."
  • Browser support. (I'm not sure about it, tested only under latests FF and chrome).

/* the container used to hide the original bg */

.icon {
  width: 60px;
  height: 60px;
  overflow: hidden;
}


/* the content */

.icon.green>span {
  -webkit-filter: drop-shadow(60px 0px green);
  filter: drop-shadow(60px 0px green);
}

.icon.red>span {
  -webkit-filter: drop-shadow(60px 0px red);
  filter: drop-shadow(60px 0px red);
}

.icon>span {
  -webkit-filter: drop-shadow(60px 0px black);
  filter: drop-shadow(60px 0px black);
  background-position: -100% 0;
  margin-left: -60px;
  display: block;
  width: 61px; /* +1px for chrome bug...*/
  height: 60px;
  background-image: url(data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2ZXJzaW9uPSIxLjEiIHg9IjBweCIgeT0iMHB4IiB2aWV3Qm94PSIwIDAgOTAgOTAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDkwIDkwIiB4bWw6c3BhY2U9InByZXNlcnZlIj48Zz48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTYxLjUxMSwyNi4xNWMtMC43MTQtMS43MzgtMS43MjMtMy4yOTgtMy4wMjYtNC42NzkgICBjLTEuMzAzLTEuMzY2LTIuODA5LTIuNDUyLTQuNTE1LTMuMjU5Yy0xLjc1NC0wLjgyMi0zLjYwMS0xLjI4OC01LjU0LTEuMzk2Yy0wLjI4LTAuMDMxLTAuNTUyLTAuMDQ3LTAuODE0LTAuMDQ3ICAgYy0wLjAxOCwwLTAuMDMxLDAtMC4wNDcsMGMtMC4zMjcsMC4wMTYtMC41NzQsMC4wMjMtMC43NDUsMC4wMjNjLTEuOTcxLDAuMTA4LTMuODQxLDAuNTc0LTUuNjA5LDEuMzk3ICAgYy0xLjcwOCwwLjgwNy0zLjIxMiwxLjg5My00LjUxNywzLjI1OWMtMS4zMTgsMS4zODEtMi4zMjcsMi45NDgtMy4wMjYsNC43MDJ2LTAuMDIzYy0wLjc0NCwxLjgxNS0xLjExOCwzLjcxNi0xLjExOCw1LjcwMiAgIGMtMC4wMTUsMi4wNjQsMC41MzcsNC4xODIsMS42NTQsNi4zNTVjMC41NzQsMS4xMzMsMS4yOTUsMi4yNSwyLjE2NCwzLjM1MmMwLjQ4MiwwLjYwNSwxLjAwMiwxLjIxLDEuNTYsMS44MTYgICBjMC4wMzEsMC4wMTYsMC4wNTUsMC4wMzksMC4wNzEsMC4wN2MwLjUyNywwLjQ5NiwwLjg5MiwwLjk3OCwxLjA5MywxLjQ0M2MwLjEwOCwwLjIzMywwLjE3OSwwLjUyLDAuMjEsMC44NjIgICBjMC4wNDYsMC4zNzEsMC4wNjksMC44MjIsMC4wNjksMS4zNXYxLjA0OGMwLDAuNjIsMC4xMTcsMS4yMTgsMC4zNDksMS43OTJjMC4yMzQsMC41NDMsMC41NiwxLjAyNCwwLjk3OCwxLjQ0M2gwLjAyNSAgIGMwLjQxOCwwLjQxOSwwLjg5MiwwLjc0NSwxLjQyLDAuOTc3aDAuMDIzYzAuNTU4LDAuMjQ5LDEuMTQ4LDAuMzczLDEuNzY5LDAuMzczaDcuMjg3YzAuNjIsMCwxLjIwOS0wLjEyNCwxLjc2OS0wLjM3MyAgIGMwLjU0My0wLjIzMSwxLjAyMy0wLjU1OCwxLjQ0My0wLjk3N2MwLjQxOC0wLjQxOSwwLjc0My0wLjksMC45NzgtMS40NDNjMC4yNDgtMC41NzQsMC4zNzEtMS4xNzIsMC4zNzEtMS43OTJ2LTEuMDQ4ICAgYzAtMC41MjcsMC4wMjMtMC45NzksMC4wNzEtMS4zNWMwLjAyOS0wLjM0MiwwLjA5Mi0wLjYzNywwLjE4Ni0wLjg4NWMwLjEwOC0wLjIzMywwLjI2NC0wLjQ3MywwLjQ2Ni0wLjcyMnYtMC4wMjMgICBjMC4xODctMC4yMzMsMC40MDMtMC40NjYsMC42NTEtMC42OTljMC4wMTYtMC4wMTYsMC4wMzEtMC4wMywwLjA0Ny0wLjA0NmMwLjU3NC0wLjYwNSwxLjEwMy0xLjIxLDEuNTgzLTEuODE2ICAgYzAuODY4LTEuMTAyLDEuNTkxLTIuMjE5LDIuMTY1LTMuMzUyYzEuMTE3LTIuMTczLDEuNjY3LTQuMjkxLDEuNjUyLTYuMzU1QzYyLjYwNSwyOS44NTksNjIuMjQsMjcuOTY2LDYxLjUxMSwyNi4xNXogICAgTTgxLjc4NSw0My4xNDJjMCw2Ljg3NS0xLjc1MywxMy4wMi01LjI2MSwxOC40MzZjLTEuMzgxLDIuMTQxLTMuMDMyLDQuMTY3LTQuOTU4LDYuMDc1Yy02Ljc1LDYuNzk3LTE0LjkxMywxMC4xOTUtMjQuNDg2LDEwLjE5NSAgIGMtNi40NTcsMC0xMi4yOTItMS41NDQtMTcuNTA1LTQuNjMyYy0wLjI0OSwwLjI5NS0wLjU2LDAuNTI3LTAuOTMyLDAuNjk4bC0xNi4xMzEsNy42NThjLTAuNTEyLDAuMjMzLTEuMDQ3LDAuMzAzLTEuNjA2LDAuMjEgICBjLTAuNTU5LTAuMDk0LTEuMDQtMC4zNDItMS40NDMtMC43NDVjLTAuNDA0LTAuNDAzLTAuNjUyLTAuODg2LTAuNzQ2LTEuNDQzYy0wLjA5My0wLjU2LTAuMDIzLTEuMDk0LDAuMjEtMS42MDVsNy42NTgtMTYuMjcxICAgYzAuMTQtMC4zMTEsMC4zMzQtMC41NzQsMC41ODMtMC43OTJjLTMuMTk3LTUuMjYxLTQuNzk2LTExLjE4OC00Ljc5Ni0xNy43ODRjMC05LjYyMSwzLjM3Ni0xNy44MDcsMTAuMTI1LTI0LjU1OCAgIGMwLjUyOC0wLjUyNywxLjA3MS0xLjA0LDEuNjMtMS41MzZjMi4yMDQtMS45NTYsNC41MzktMy41Nyw3LjAwNi00Ljg0MkMzNS45NDUsOS42OTIsNDEuMjYsOC40MzYsNDcuMDgsOC40MzYgICBjOS41NzMsMCwxNy43MzYsMy4zODIsMjQuNDg2LDEwLjE0OGM2LjQyNiw2LjM3OCw5LjgyNCwxNC4wMjksMTAuMTk1LDIyLjk1MkM4MS43NzgsNDIuMDYzLDgxLjc4NSw0Mi41OTksODEuNzg1LDQzLjE0MnogICAgTTUxLjM4NiwyNS4yNjZjLTAuNzE0LTAuMzI2LTEuNDU5LTAuNTEzLTIuMjM1LTAuNTU5Yy0wLjQ4LTAuMDMxLTAuODc2LTAuMjI1LTEuMTg4LTAuNTgzYy0wLjMxMS0wLjM0LTAuNDU3LTAuNzUyLTAuNDQxLTEuMjMzICAgYzAuMDMxLTAuNDY2LDAuMjI1LTAuODU0LDAuNTgyLTEuMTY1YzAuMzU3LTAuMzEsMC43NjktMC40NTcsMS4yMzQtMC40NDFjMS4yMjYsMC4wNzcsMi4zOTcsMC4zOCwzLjUxNSwwLjkwNyAgIGMxLjA2OSwwLjQ5NywyLjAxOCwxLjE3OSwyLjg0LDIuMDQ5YzAuODA3LDAuODY5LDEuNDM1LDEuODU0LDEuODg0LDIuOTU2YzAuNDY2LDEuMTMzLDAuNjk5LDIuMzIsMC42OTksMy41NjIgICBjMCwwLjQ2NS0wLjE3MSwwLjg2OS0wLjUxMiwxLjIxYy0wLjMyNSwwLjMyNi0wLjcyMiwwLjQ4OS0xLjE4OCwwLjQ4OWMtMC40OCwwLTAuODg0LTAuMTYzLTEuMjEtMC40ODkgICBjLTAuMzQyLTAuMzQxLTAuNTEzLTAuNzQ2LTAuNTEzLTEuMjFjMC0wLjc5Mi0wLjE0Ni0xLjU1Mi0wLjQ0MS0yLjI4MWMtMC4yNzktMC42OTktMC42ODMtMS4zMjctMS4yMTEtMS44ODYgICBTNTIuMDY3LDI1LjU5MSw1MS4zODYsMjUuMjY2eiBNNTcuNzg3LDM1LjM2OGMwLDAuNTEyLTAuMTg4LDAuOTU0LTAuNTYsMS4zMjZjLTAuMzU2LDAuMzU3LTAuOCwwLjUzNi0xLjMyNiwwLjUzNiAgIGMtMC41MTIsMC0wLjk0Ni0wLjE3OS0xLjMwMy0wLjUzNmMtMC4zNzQtMC4zNzItMC41Ni0wLjgxNC0wLjU2LTEuMzI2YzAtMC41MTMsMC4xODYtMC45NTYsMC41Ni0xLjMyNyAgIGMwLjM1Ni0wLjM1NywwLjc5MS0wLjUzNiwxLjMwMy0wLjUzNmMwLjUyNiwwLDAuOTcsMC4xNzgsMS4zMjYsMC41MzZDNTcuNiwzNC40MTMsNTcuNzg3LDM0Ljg1NSw1Ny43ODcsMzUuMzY4eiBNNTEuODk3LDU0LjcxMSAgIEg0My40Yy0wLjcxMiwwLTEuMzE4LDAuMjU2LTEuODE1LDAuNzY5Yy0wLjUxMiwwLjQ5Ny0wLjc2OSwxLjA5NC0wLjc2OSwxLjc5MmMwLDAuNzE0LDAuMjQ5LDEuMzE5LDAuNzQ2LDEuODE1bDAuMDIzLDAuMDI0ICAgYzAuNDk3LDAuNDk2LDEuMTAzLDAuNzQ0LDEuODE1LDAuNzQ0aDguNDk3YzAuNzE1LDAsMS4zMTgtMC4yNDgsMS44MTUtMC43NDRjMC40OTctMC41MTMsMC43NDUtMS4xMjYsMC43NDUtMS44NCAgIGMwLTAuNjk4LTAuMjQ4LTEuMjk1LTAuNzQ1LTEuNzkydi0wLjAyM0M1My4yMDEsNTQuOTU5LDUyLjU5Niw1NC43MTEsNTEuODk3LDU0LjcxMXogTTQyLjcyNiw2Mi40MzhoLTAuMDIzICAgYy0wLjQ5NywwLjQ5Ny0wLjc0NSwxLjEwMy0wLjc0NSwxLjgxNnMwLjI1NywxLjMxOCwwLjc2OSwxLjgxNWMwLjQ5NywwLjQ5NywxLjEwMiwwLjc0NSwxLjgxNiwwLjc0NWg2LjEyMiAgIGMwLjY5NywwLDEuMjk1LTAuMjQ4LDEuNzkyLTAuNzQ1aDAuMDIyYzAuNDk3LTAuNDk3LDAuNzQ2LTEuMTAyLDAuNzQ2LTEuODE1cy0wLjI0OS0xLjMxOS0wLjc0Ni0xLjgxNiAgIGMtMC41MTItMC41MTItMS4xMTctMC43NjgtMS44MTQtMC43NjhoLTYuMTIyQzQzLjgyOCw2MS42NzEsNDMuMjIzLDYxLjkyNyw0Mi43MjYsNjIuNDM4eiIvPjwvZz48L3N2Zz4=);
}
<div class="icon">
  <span></span>
</div>
<div class="icon green">
  <span></span>
</div>
<div class="icon red">
  <span></span>
</div>

7
  • 1
    Super clever, awesome! This works for me, appreciate it
    – Megaroeny
    Commented May 1, 2018 at 16:14
  • I believe this is a better solution since it is 100% accurate with the color every time. Commented May 30, 2019 at 20:51
  • Code as-is shows a blank page (W10 FF 69b). Nothing wrong with the icon, though (checked separate SVG). Commented Aug 14, 2019 at 1:26
  • Adding background-color: black; to .icon>span makes this work for FF 69b. However, doesn't show icon. Commented Aug 14, 2019 at 1:54
  • 2
    DOES NOT WORK IN SAFARI! 😔
    – zen
    Commented Mar 29, 2020 at 19:02
31

I started with this answer using a svg filter and made the following modifications:

SVG filter from data url

If you don't want to define the SVG filter somewhere in your the markup, you can use a data url instead (replace R, G, B and A with the desired color):

filter: url('data:image/svg+xml;utf8,\
  <svg xmlns="http://www.w3.org/2000/svg">\
    <filter id="recolor" color-interpolation-filters="sRGB">\
      <feColorMatrix type="matrix" values="\
        0 0 0 0 R\
        0 0 0 0 G\
        0 0 0 0 B\
        0 0 0 A 0\
      "/>\
    </filter>\
  </svg>\
  #recolor');

Grayscale fallback

If the version above does not work, you could also add a grayscale fallback.

The saturate and brightness functions turn any color to black (you don't have to include that if the color is already black), invert then brightens it with the desired lightness (L) and optionally you can also specify the opacity (A).

filter: saturate(0%) brightness(0%) invert(L) opacity(A);

SCSS mixin

If you want to specify the color dynamically, you could use the following SCSS mixin:

@mixin recolor($color: #000, $opacity: 1) {
  $r: red($color) / 255;
  $g: green($color) / 255;
  $b: blue($color) / 255;
  $a: $opacity;

  // grayscale fallback if SVG from data url is not supported
  $lightness: lightness($color);
  filter: saturate(0%) brightness(0%) invert($lightness) opacity($opacity);

  // color filter
  $svg-filter-id: "recolor";
  filter: url('data:image/svg+xml;utf8,\
    <svg xmlns="http://www.w3.org/2000/svg">\
      <filter id="#{$svg-filter-id}" color-interpolation-filters="sRGB">\
        <feColorMatrix type="matrix" values="\
          0 0 0 0 #{$r}\
          0 0 0 0 #{$g}\
          0 0 0 0 #{$b}\
          0 0 0 #{$a} 0\
        "/>\
      </filter>\
    </svg>\
    ##{$svg-filter-id}');
}

Example usage:

.icon-green {
  @include recolor(#00fa86, 0.8);
}

Advantages:

  • No Javascript.
  • No additional HTML elements.
  • If CSS filters are supported, but the SVG filter does not work, there is a grayscale fallback.
  • If you use the mixin, the usage is pretty straightforward (see example above).
  • The color is more readable and easier to modify than the sepia trick (RGBA components in pure CSS and you can even use HEX colors in SCSS).
  • Avoids the weird behavior of hue-rotate.

Caveats:

  • Not all browsers support SVG filters from a data url (especially the id hash), but it works in current Firefox and Chromium browsers (and maybe others).
  • If you want to specify the color dynamically, you have to use a SCSS mixin.
  • Pure CSS version is a bit ugly, if you want many different colors you have to include the SVG multiple times.
12
  • 1
    oh that is perfect, this is exactly what I was looking which was to use everything in SASS awesome thanks a lot!
    – ghiscoding
    Commented Aug 11, 2020 at 14:30
  • 1
    @ghiscoding I'm glad it helped! Commented Aug 13, 2020 at 15:28
  • 1
    @Reza Unfortunately I don't think that is possible, because the url() function in CSS doesn't support interpolation. The RGB values are interpolated in the mixin at compile time, so this is possible with SCSS variables. And because the values of CSS variables are available at runtime, you cannot get their value from SCSS at compile time either. So you either have to: get the color from a variable and interpolate it in the url both at compile or both at runtime, which is not possible. Commented Nov 5, 2020 at 13:34
  • 3
    Seems not to work on Safari (Apple MacBook).
    – fabpico
    Commented Feb 1, 2022 at 8:22
  • 1
    @DavidDostal I know, just to add a specific info.
    – fabpico
    Commented Feb 1, 2022 at 14:56
29

You can make this all very simple by just using a SVG filter referenced from CSS. You only need a single feColorMatrix to do a recolor. This one recolors to yellow. The fifth column in the feColorMatrix holds the RGB target values on the unit scale. (for yellow - it's 1,1,0)

.icon {
  filter: url(#recolorme); 
}
<svg height="0px" width="0px">
<defs>
  #ffff00
  <filter id="recolorme" color-interpolation-filters="sRGB">
    <feColorMatrix type="matrix" values="0 0 0 0 1
                                         0 0 0 0 1
                                         0 0 0 0 0
                                         0 0 0 1 0"/>
  </filter>
</defs>
</svg>


<img class="icon" src="https://www.nouveauelevator.com/image/black-icon/android.png">

6
  • 1
    An interesting solution but it seems that it does not allow controlling the target color via CSS.
    – glebm
    Commented Mar 3, 2019 at 19:26
  • 1
    You have to define a new filter for each color you want to apply. But it's fully accurate. hue-rotate is an approximation that clips certain colors - meaning that you can't achieve certain colors accurately using it - as the answers above attest. What we really need is a recolor() CSS filter shorthand. Commented Mar 4, 2019 at 20:59
  • MultiplyByZer0's answer calculates a series of filters that achieve with very high accuracy, without modifying HTML. A true hue-rotate in browsers would be nice yeah.
    – glebm
    Commented Mar 8, 2019 at 2:02
  • 3
    it seems this only produces accurate RGB colors for black source images when you add "color-interpolation-filters"="sRGB" to the feColorMatrix.
    – John Smith
    Commented Apr 16, 2019 at 7:34
  • Edge 12-18 are left out as they are not supporting url function caniuse.com/#search=svg%20filter
    – Volker E.
    Commented Jun 5, 2019 at 18:38
5

To expand on David Dostals SCSS Mixin I removed the opacity parameter and updated the syntax to match the new SASS division syntax.

Removing the opacity parameter and taking opacity directly from the color value allows me to take any given hex/rgba color (e.g. from SASS variable) and apply the filter accordingly.

@use "sass:math";

@mixin recolor($color: #000) {
  $r: math.div(red($color), 255);
  $g: math.div(green($color), 255);
  $b: math.div(blue($color), 255);
  $a: alpha($color);
 
  // grayscale fallback if SVG from data url is not supported
  $lightness: lightness($color);
  filter: saturate(0%) brightness(0%) invert($lightness) opacity($a);

  // color filter
  $svg-filter-id: "recolor";
  filter: url('data:image/svg+xml;utf8,\
  <svg xmlns="http://www.w3.org/2000/svg">\
    <filter id="#{$svg-filter-id}" color-interpolation-filters="sRGB">\
      <feColorMatrix type="matrix" values="\
       0 0 0 0 #{$r}\
       0 0 0 0 #{$g}\
       0 0 0 0 #{$b}\
       0 0 0 #{$a} 0\
      "/>\
    </filter>\
  </svg>\
  ##{$svg-filter-id}');
}
// applied with
@include recolor($arbitrary-color);
3

"use strict";

class Color {
    constructor(r, g, b) { this.set(r, g, b); }
    toString() { return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(this.b)})`; }

    set(r, g, b) {
        this.r = this.clamp(r);
        this.g = this.clamp(g);
        this.b = this.clamp(b);
    }

    hueRotate(angle = 0) {
        angle = angle / 180 * Math.PI;
        let sin = Math.sin(angle);
        let cos = Math.cos(angle);

        this.multiply([
            0.213 + cos * 0.787 - sin * 0.213, 0.715 - cos * 0.715 - sin * 0.715, 0.072 - cos * 0.072 + sin * 0.928,
            0.213 - cos * 0.213 + sin * 0.143, 0.715 + cos * 0.285 + sin * 0.140, 0.072 - cos * 0.072 - sin * 0.283,
            0.213 - cos * 0.213 - sin * 0.787, 0.715 - cos * 0.715 + sin * 0.715, 0.072 + cos * 0.928 + sin * 0.072
        ]);
    }

    grayscale(value = 1) {
        this.multiply([
            0.2126 + 0.7874 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 - 0.0722 * (1 - value),
            0.2126 - 0.2126 * (1 - value), 0.7152 + 0.2848 * (1 - value), 0.0722 - 0.0722 * (1 - value),
            0.2126 - 0.2126 * (1 - value), 0.7152 - 0.7152 * (1 - value), 0.0722 + 0.9278 * (1 - value)
        ]);
    }

    sepia(value = 1) {
        this.multiply([
            0.393 + 0.607 * (1 - value), 0.769 - 0.769 * (1 - value), 0.189 - 0.189 * (1 - value),
            0.349 - 0.349 * (1 - value), 0.686 + 0.314 * (1 - value), 0.168 - 0.168 * (1 - value),
            0.272 - 0.272 * (1 - value), 0.534 - 0.534 * (1 - value), 0.131 + 0.869 * (1 - value)
        ]);
    }

    saturate(value = 1) {
        this.multiply([
            0.213 + 0.787 * value, 0.715 - 0.715 * value, 0.072 - 0.072 * value,
            0.213 - 0.213 * value, 0.715 + 0.285 * value, 0.072 - 0.072 * value,
            0.213 - 0.213 * value, 0.715 - 0.715 * value, 0.072 + 0.928 * value
        ]);
    }

    multiply(matrix) {
        let newR = this.clamp(this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]);
        let newG = this.clamp(this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]);
        let newB = this.clamp(this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]);
        this.r = newR; this.g = newG; this.b = newB;
    }

    brightness(value = 1) { this.linear(value); }
    contrast(value = 1) { this.linear(value, -(0.5 * value) + 0.5); }

    linear(slope = 1, intercept = 0) {
        this.r = this.clamp(this.r * slope + intercept * 255);
        this.g = this.clamp(this.g * slope + intercept * 255);
        this.b = this.clamp(this.b * slope + intercept * 255);
    }

    invert(value = 1) {
        this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
        this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
        this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
    }

    hsl() { // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
        let r = this.r / 255;
        let g = this.g / 255;
        let b = this.b / 255;
        let max = Math.max(r, g, b);
        let min = Math.min(r, g, b);
        let h, s, l = (max + min) / 2;

        if(max === min) {
            h = s = 0;
        } else {
            let d = max - min;
            s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
            switch(max) {
                case r: h = (g - b) / d + (g < b ? 6 : 0); break;
                case g: h = (b - r) / d + 2; break;
                case b: h = (r - g) / d + 4; break;
            } h /= 6;
        }

        return {
            h: h * 100,
            s: s * 100,
            l: l * 100
        };
    }

    clamp(value) {
        if(value > 255) { value = 255; }
        else if(value < 0) { value = 0; }
        return value;
    }
}

class Solver {
    constructor(target) {
        this.target = target;
        this.targetHSL = target.hsl();
        this.reusedColor = new Color(0, 0, 0); // Object pool
    }

    solve() {
        let result = this.solveNarrow(this.solveWide());
        return {
            values: result.values,
            loss: result.loss,
            filter: this.css(result.values)
        };
    }

    solveWide() {
        const A = 5;
        const c = 15;
        const a = [60, 180, 18000, 600, 1.2, 1.2];

        let best = { loss: Infinity };
        for(let i = 0; best.loss > 25 && i < 3; i++) {
            let initial = [50, 20, 3750, 50, 100, 100];
            let result = this.spsa(A, a, c, initial, 1000);
            if(result.loss < best.loss) { best = result; }
        } return best;
    }

    solveNarrow(wide) {
        const A = wide.loss;
        const c = 2;
        const A1 = A + 1;
        const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
        return this.spsa(A, a, c, wide.values, 500);
    }

    spsa(A, a, c, values, iters) {
        const alpha = 1;
        const gamma = 0.16666666666666666;

        let best = null;
        let bestLoss = Infinity;
        let deltas = new Array(6);
        let highArgs = new Array(6);
        let lowArgs = new Array(6);

        for(let k = 0; k < iters; k++) {
            let ck = c / Math.pow(k + 1, gamma);
            for(let i = 0; i < 6; i++) {
                deltas[i] = Math.random() > 0.5 ? 1 : -1;
                highArgs[i] = values[i] + ck * deltas[i];
                lowArgs[i]  = values[i] - ck * deltas[i];
            }

            let lossDiff = this.loss(highArgs) - this.loss(lowArgs);
            for(let i = 0; i < 6; i++) {
                let g = lossDiff / (2 * ck) * deltas[i];
                let ak = a[i] / Math.pow(A + k + 1, alpha);
                values[i] = fix(values[i] - ak * g, i);
            }

            let loss = this.loss(values);
            if(loss < bestLoss) { best = values.slice(0); bestLoss = loss; }
        } return { values: best, loss: bestLoss };

        function fix(value, idx) {
            let max = 100;
            if(idx === 2 /* saturate */) { max = 7500; }
            else if(idx === 4 /* brightness */ || idx === 5 /* contrast */) { max = 200; }

            if(idx === 3 /* hue-rotate */) {
                if(value > max) { value = value % max; }
                else if(value < 0) { value = max + value % max; }
            } else if(value < 0) { value = 0; }
            else if(value > max) { value = max; }
            return value;
        }
    }

    loss(filters) { // Argument is array of percentages.
        let color = this.reusedColor;
        color.set(0, 0, 0);

        color.invert(filters[0] / 100);
        color.sepia(filters[1] / 100);
        color.saturate(filters[2] / 100);
        color.hueRotate(filters[3] * 3.6);
        color.brightness(filters[4] / 100);
        color.contrast(filters[5] / 100);

        let colorHSL = color.hsl();
        return Math.abs(color.r - this.target.r)
            + Math.abs(color.g - this.target.g)
            + Math.abs(color.b - this.target.b)
            + Math.abs(colorHSL.h - this.targetHSL.h)
            + Math.abs(colorHSL.s - this.targetHSL.s)
            + Math.abs(colorHSL.l - this.targetHSL.l);
    }

    css(filters) {
        function fmt(idx, multiplier = 1) { return Math.round(filters[idx] * multiplier); }
        return `filter: invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(2)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(5)}%);`;
    }
}

$("button.execute").click(() => {
    let rgb = $("input.target").val().split(",");
    if (rgb.length !== 3) { alert("Invalid format!"); return; }

    let color = new Color(rgb[0], rgb[1], rgb[2]);
    let solver = new Solver(color);
    let result = solver.solve();

    let lossMsg;
    if (result.loss < 1) {
        lossMsg = "This is a perfect result.";
    } else if (result.loss < 5) {
        lossMsg = "The is close enough.";
    } else if(result.loss < 15) {
        lossMsg = "The color is somewhat off. Consider running it again.";
    } else {
        lossMsg = "The color is extremely off. Run it again!";
    }

    $(".realPixel").css("background-color", color.toString());
    $(".filterPixel").attr("style", result.filter);
    $(".filterDetail").text(result.filter);
    $(".lossDetail").html(`Loss: ${result.loss.toFixed(1)}. <b>${lossMsg}</b>`);
});
.pixel {
    display: inline-block;
    background-color: #000;
    width: 50px;
    height: 50px;
}

.filterDetail {
    font-family: "Consolas", "Menlo", "Ubuntu Mono", monospace;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>

<input class="target" type="text" placeholder="r, g, b" value="250, 150, 50" />
<button class="execute">Compute Filters</button>

<p>Real pixel, color applied through CSS <code>background-color</code>:</p>
<div class="pixel realPixel"></div>

<p>Filtered pixel, color applied through CSS <code>filter</code>:</p>
<div class="pixel filterPixel"></div>

<p class="filterDetail"></p>
<p class="lossDetail"></p>

1
  • 1
    Welcome to SO and thanks for taking the time to answer to one question for the first time! Just a small tip: it's nice to add some comments to describe what the code does, in order to help the answer to be complete and easier to understand.
    – juagicre
    Commented Mar 9, 2022 at 16:30
3

I noticed that the example of the treatment via an SVG filter was incomplete, I wrote mine (which works perfectly): (see Michael Mullany answer) so here is the way to get any color you want :

PickColor.onchange=()=>{
    RGBval.textContent = PickColor.value;

    let 
    HexT = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(PickColor.value),
    r = parseInt(HexT[1], 16),
    g = parseInt(HexT[2], 16),
    b = parseInt(HexT[3], 16);

    FilterVal.textContent =  SetFilter( r, g, b);
}
function SetFilter( r, g, b )
{
    const Matrix  = document.querySelector('#FilterSVG feColorMatrix');
    r = r/255;
    g = g/255;
    b = b/255;

    Matrix.setAttribute("values",  "0 0 0 0 "+r+" 0 0 0 0 "+g+ " 0 0 0 0 "+b+" 0 0 0 1 0");

    return "\n 0 0 0 0 "+r+"\n 0 0 0 0 "+g+ "\n 0 0 0 0 "+b+"\n 0 0 0 1 0"
}
#RGBval    { text-transform: uppercase }
#PickColor { height: 50px; margin: 0 20px }
th         { background-color: lightblue; padding: 5px 20px }
pre        { margin: 0 15px }
#ImgTest   { filter: url(#FilterSVG) }
<svg height="0px" width="0px">
    <defs>
      <filter id="FilterSVG" color-interpolation-filters="sRGB">
        <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
      </filter>
    </defs>
  </svg>

  <table>
    <caption>SVG method</caption>
    <tr> <th>Image</th> <th>Color</th> </tr>
    <tr>
      <td><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Doom_%E2%80%93_Game%E2%80%99s_logo.svg/120px-Doom_%E2%80%93_Game%E2%80%99s_logo.svg.png" id="ImgTest" /></td> 
      <td><input type="color" value="#000000"  id="PickColor" ></td>
    </tr>
    <tr> <td>.</td> <td>.</td> </tr>
    <tr> <th>Filter value </th> <th>#RBG target</th> </tr>
    <tr>
      <td><pre id="FilterVal">
    0 0 0 0 0
    0 0 0 0 0
    0 0 0 0 0
    0 0 0 1 0</pre></td>
        <td id="RGBval">#000000</td>
    </tr>
  </table>

Here is a second solution, by using SVG Filter only in code => URL.createObjectURL

const
  SVG_Filter = {
    init(ImgID) 
    {
      this.Img = document.getElementById(ImgID);
      let
        NS = 'http://www.w3.org/2000/svg';

      this.SVG    = document.createElementNS(NS,'svg'),
      this.filter = document.createElementNS(NS,'filter'),
      this.matrix = document.createElementNS(NS,'feColorMatrix');

      this.filter.setAttribute( 'id', 'FilterSVG');
      this.filter.setAttribute( 'color-interpolation-filters', 'sRGB');

      this.matrix.setAttribute( 'type', 'matrix');
      this.matrix.setAttribute('values', '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0');

      this.filter.appendChild(this.matrix);
      this.SVG.appendChild(this.filter);

      this.xXMLs = new XMLSerializer();
    },
    SetColor( r, g, b )
    {
      r = r/255;
      g = g/255;
      b = b/255;

      this.matrix.setAttribute('values', '0 0 0 0 '+r+' 0 0 0 0 '+g+ ' 0 0 0 0 '+b+' 0 0 0 1 0');

      let
        xBlob = new Blob( [ this.xXMLs.serializeToString(this.SVG) ], { type: 'image/svg+xml' });
        xURL  = URL.createObjectURL(xBlob);

      this.Img.style.filter = 'url(' + xURL + '#FilterSVG)';

      return '\n 0 0 0 0 '+r+'\n 0 0 0 0 '+g+ '\n 0 0 0 0 '+b+'\n 0 0 0 1 0';
    }
  }

SVG_Filter.init('ImgTest');

PickColor.onchange=()=>{
  RGBval.textContent = PickColor.value;

  let 
    HexT = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(PickColor.value),
    r = parseInt(HexT[1], 16),
    g = parseInt(HexT[2], 16),
    b = parseInt(HexT[3], 16);

  FilterVal.textContent = SVG_Filter.SetColor( r, g, b );
}
#RGBval    { text-transform: uppercase }
#PickColor { height: 50px; margin: 0 20px }
th         { background-color: lightblue; padding: 5px 20px }
pre        { margin: 0 15px }
#PickColor { width:90px; height:28px; }
<table>
  <caption>SVG method</caption>
  <tr> <th>Image</th> <th>Color</th> </tr>
  <tr>
    <td><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Doom_%E2%80%93_Game%E2%80%99s_logo.svg/120px-Doom_%E2%80%93_Game%E2%80%99s_logo.svg.png" id="ImgTest" /></td> 
    <td><input type="color" value="#E2218A" id="PickColor" ></td>
  </tr>
  <tr> <td>.</td> <td>.</td> </tr>
  <tr> <th>Filter value </th> <th>#RBG target</th> </tr>
  <tr>
    <td><pre id="FilterVal">
  0 0 0 0 0
  0 0 0 0 0
  0 0 0 0 0
  0 0 0 1 0</pre></td>
      <td id="RGBval">#000000</td>
  </tr>
</table>

2

Based on previous, amazing answers, I've tried to make the code a bit easier to understand for me.

I've made it more functional, added TypeScript typing where I was feeling confident enough and also renamed some variables when I understood what is happening.

import ColorParser from 'color';

function parseColorToRgb(input: string) {
  const colorInstance = new ColorParser(input);

  return new RgbColor(
    colorInstance.red(),
    colorInstance.green(),
    colorInstance.blue(),
  );
}

function clampRgbPart(value: number): number {
  if (value > 255) {
    return 255;
  }

  if (value < 0) {
    return 0;
  }

  return value;
}

class RgbColor {
  constructor(public red: number, public green: number, public blue: number) {}

  toString() {
    return `rgb(${Math.round(this.red)}, ${Math.round(
      this.green,
    )}, ${Math.round(this.blue)})`;
  }

  set(r: number, g: number, b: number) {
    this.red = clampRgbPart(r);
    this.green = clampRgbPart(g);
    this.blue = clampRgbPart(b);
  }

  hueRotate(angle = 0) {
    angle = (angle / 180) * Math.PI;
    const sin = Math.sin(angle);
    const cos = Math.cos(angle);

    this.multiply([
      0.213 + cos * 0.787 - sin * 0.213,
      0.715 - cos * 0.715 - sin * 0.715,
      0.072 - cos * 0.072 + sin * 0.928,
      0.213 - cos * 0.213 + sin * 0.143,
      0.715 + cos * 0.285 + sin * 0.14,
      0.072 - cos * 0.072 - sin * 0.283,
      0.213 - cos * 0.213 - sin * 0.787,
      0.715 - cos * 0.715 + sin * 0.715,
      0.072 + cos * 0.928 + sin * 0.072,
    ]);
  }

  grayscale(value = 1) {
    this.multiply([
      0.2126 + 0.7874 * (1 - value),
      0.7152 - 0.7152 * (1 - value),
      0.0722 - 0.0722 * (1 - value),
      0.2126 - 0.2126 * (1 - value),
      0.7152 + 0.2848 * (1 - value),
      0.0722 - 0.0722 * (1 - value),
      0.2126 - 0.2126 * (1 - value),
      0.7152 - 0.7152 * (1 - value),
      0.0722 + 0.9278 * (1 - value),
    ]);
  }

  sepia(value = 1) {
    this.multiply([
      0.393 + 0.607 * (1 - value),
      0.769 - 0.769 * (1 - value),
      0.189 - 0.189 * (1 - value),
      0.349 - 0.349 * (1 - value),
      0.686 + 0.314 * (1 - value),
      0.168 - 0.168 * (1 - value),
      0.272 - 0.272 * (1 - value),
      0.534 - 0.534 * (1 - value),
      0.131 + 0.869 * (1 - value),
    ]);
  }

  saturate(value = 1) {
    this.multiply([
      0.213 + 0.787 * value,
      0.715 - 0.715 * value,
      0.072 - 0.072 * value,
      0.213 - 0.213 * value,
      0.715 + 0.285 * value,
      0.072 - 0.072 * value,
      0.213 - 0.213 * value,
      0.715 - 0.715 * value,
      0.072 + 0.928 * value,
    ]);
  }

  multiply(matrix: number[]) {
    const newR = clampRgbPart(
      this.red * matrix[0] + this.green * matrix[1] + this.blue * matrix[2],
    );
    const newG = clampRgbPart(
      this.red * matrix[3] + this.green * matrix[4] + this.blue * matrix[5],
    );
    const newB = clampRgbPart(
      this.red * matrix[6] + this.green * matrix[7] + this.blue * matrix[8],
    );
    this.red = newR;
    this.green = newG;
    this.blue = newB;
  }

  brightness(value = 1) {
    this.linear(value);
  }

  contrast(value = 1) {
    this.linear(value, -(0.5 * value) + 0.5);
  }

  linear(slope = 1, intercept = 0) {
    this.red = clampRgbPart(this.red * slope + intercept * 255);
    this.green = clampRgbPart(this.green * slope + intercept * 255);
    this.blue = clampRgbPart(this.blue * slope + intercept * 255);
  }

  invert(value = 1) {
    this.red = clampRgbPart((value + (this.red / 255) * (1 - 2 * value)) * 255);
    this.green = clampRgbPart(
      (value + (this.green / 255) * (1 - 2 * value)) * 255,
    );
    this.blue = clampRgbPart(
      (value + (this.blue / 255) * (1 - 2 * value)) * 255,
    );
  }

  applyFilters(filters: Filters) {
    this.set(0, 0, 0);
    this.invert(filters[0] / 100);
    this.sepia(filters[1] / 100);
    this.saturate(filters[2] / 100);
    this.hueRotate(filters[3] * 3.6);
    this.brightness(filters[4] / 100);
    this.contrast(filters[5] / 100);
  }

  hsl(): HSLData {
    // Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
    const r = this.red / 255;
    const g = this.green / 255;
    const b = this.blue / 255;
    const max = Math.max(r, g, b);
    const min = Math.min(r, g, b);
    let h: number,
      s: number,
      l = (max + min) / 2;

    if (max === min) {
      h = s = 0;
    } else {
      const d = max - min;
      s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
      switch (max) {
        case r:
          h = (g - b) / d + (g < b ? 6 : 0);
          break;

        case g:
          h = (b - r) / d + 2;
          break;

        case b:
          h = (r - g) / d + 4;
          break;
      }
      h! /= 6;
    }

    return {
      h: h! * 100,
      s: s * 100,
      l: l * 100,
    };
  }
}

interface HSLData {
  h: number;
  s: number;
  l: number;
}

interface ColorFilterSolveResult {
  loss: number;
  filters: Filters;
}

const reusedColor = new RgbColor(0, 0, 0);

function formatFilterValue(value: number, multiplier = 1) {
  return Math.round(value * multiplier);
}

type Filters = [
  invert: number,
  sepia: number,
  saturate: number,
  hueRotate: number,
  brightness: number,
  contrast: number,
];

function convertFiltersListToCSSFilter(filters: Filters) {
  function fmt(idx: number, multiplier = 1) {
    return Math.round(filters[idx] * multiplier);
  }
  const [invert, sepia, saturate, hueRotate, brightness, contrast] = filters;
  return `filter: invert(${formatFilterValue(
    invert,
  )}%) sepia(${formatFilterValue(sepia)}%) saturate(${formatFilterValue(
    saturate,
  )}%) hue-rotate(${formatFilterValue(
    hueRotate,
    3.6,
  )}deg) brightness(${formatFilterValue(
    brightness,
  )}%) contrast(${formatFilterValue(contrast)}%);`;
}

function calculateLossForFilters(
  filters: Filters,
  targetColor: RgbColor,
  targetHSL: HSLData,
) {
  reusedColor.applyFilters(filters);
  const actualHSL = reusedColor.hsl();

  return (
    Math.abs(reusedColor.red - targetColor.red) +
    Math.abs(reusedColor.green - targetColor.green) +
    Math.abs(reusedColor.blue - targetColor.blue) +
    Math.abs(actualHSL.h - targetHSL.h) +
    Math.abs(actualHSL.s - targetHSL.s) +
    Math.abs(actualHSL.l - targetHSL.l)
  );
}



export function solveColor(input: string) {
  const targetColor = parseColorToRgb(input);
  const targetHSL = targetColor.hsl();

  function improveInitialSolveResult(initialResult: ColorFilterSolveResult) {
    const A = initialResult.loss;
    const c = 2;
    const A1 = A + 1;
    const a: Filters = [
      0.25 * A1,
      0.25 * A1,
      A1,
      0.25 * A1,
      0.2 * A1,
      0.2 * A1,
    ];
    return findColorFilters(A, a, c, initialResult.filters, 500);
  }

  function findColorFilters(
    initialLoss: number,
    filters: Filters,
    c: number,
    values: Filters,
    iterationsCount: number,
  ): ColorFilterSolveResult {
    const alpha = 1;
    const gamma = 0.16666666666666666;

    let best = null;
    let bestLoss = Infinity;
    const deltas = new Array(6);
    const highArgs = new Array(6) as Filters;
    const lowArgs = new Array(6) as Filters;

    for (
      let iterationIndex = 0;
      iterationIndex < iterationsCount;
      iterationIndex++
    ) {
      const ck = c / Math.pow(iterationIndex + 1, gamma);
      for (let i = 0; i < 6; i++) {
        deltas[i] = Math.random() > 0.5 ? 1 : -1;
        highArgs[i] = values[i] + ck * deltas[i];
        lowArgs[i] = values[i] - ck * deltas[i];
      }

      const lossDiff =
        calculateLossForFilters(highArgs, targetColor, targetHSL) -
        calculateLossForFilters(lowArgs, targetColor, targetHSL);

      for (let i = 0; i < 6; i++) {
        const g = (lossDiff / (2 * ck)) * deltas[i];
        const ak =
          filters[i] / Math.pow(initialLoss + iterationIndex + 1, alpha);
        values[i] = fix(values[i] - ak * g, i);
      }

      const loss = calculateLossForFilters(values, targetColor, targetHSL);
      if (loss < bestLoss) {
        best = values.slice(0) as Filters;
        bestLoss = loss;
      }
    }
    return { filters: best!, loss: bestLoss };

    function fix(value: number, idx: number) {
      let max = 100;
      if (idx === 2 /* saturate */) {
        max = 7500;
      } else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) {
        max = 200;
      }

      if (idx === 3 /* hue-rotate */) {
        if (value > max) {
          value %= max;
        } else if (value < 0) {
          value = max + (value % max);
        }
      } else if (value < 0) {
        value = 0;
      } else if (value > max) {
        value = max;
      }
      return value;
    }
  }

  function solveInitial(): ColorFilterSolveResult {
    const A = 5;
    const c = 15;
    const a: Filters = [60, 180, 18000, 600, 1.2, 1.2];

    let best: ColorFilterSolveResult = {
      loss: Infinity,
      filters: [0, 0, 0, 0, 0, 0],
    };
    for (let i = 0; best.loss > 25 && i < 3; i++) {
      const initial: Filters = [50, 20, 3750, 50, 100, 100];
      const result = findColorFilters(A, a, c, initial, 1000);
      if (result.loss < best.loss) {
        best = result;
      }
    }
    return best;
  }

  const result = improveInitialSolveResult(solveInitial());

  return convertFiltersListToCSSFilter(result.filters)
}

I'm also using npm color package so the main function will accept pretty much any valid color input string (hex, rgb etc)


A few notes I would add:

  • you probably want to add some solid caching layer to that
  • if you have limited count of colors you need filters for, it could probably be good idea to 'pre-calculate' filters for them and make some hard-coded map of those colors so end user will never have to run calculation for them as those are quite heavy

Here is my caching layer


const colorFiltersCache = new Map<string, string>();

export function cachedSolveColor(input: string) {
  const existingResult = colorFiltersCache.get(input);

  if (existingResult) {
    return existingResult;
  }

  const newResult = solveColor(input);

  colorFiltersCache.set(input, newResult);

  return newResult;
}
1

@David Dostal's answer and @silvan's answer are great solutions and don't have this issue of loss like some other answers do. However, I wanted to use this with LESS instead of SASS, so I converted the mixin. Here's the LESS version if anyone is interested:

.recolor(@color: #333) {
    @r: red(@color) / 255;
    @g: green(@color) / 255;
    @b: blue(@color) / 255;
    @a: alpha(@color);
  
    // grayscale fallback if SVG from data url is not supported
    @lightness: lightness(@color);
    filter: saturate(0%) brightness(0%) invert(@lightness) opacity(@a);
  
    // color filter
    @svg-filter-id: "recolor";
    filter: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><filter id="@{svg-filter-id}" color-interpolation-filters="sRGB"><feColorMatrix type="matrix" values="0 0 0 0 @{r} 0 0 0 0 @{g} 0 0 0 0 @{b} 0 0 0 @{a} 0"/></filter></svg> #@{svg-filter-id}');
}

Example Usage:

.icon-green {
  .recolor(rgba(0, 250, 134, 0.8));
}
0

Issue with background-color: If you set a Background color, which is inherited by the svg, then set the background of the svg to transparent, before apply the filter (otherwise invert will modifiy your background)

1
  • I am not sure how this addresses the question Commented Feb 15, 2023 at 17:58
-1

just use

fill: #000000

The fill property in CSS is for filling in the color of a SVG shape. The fill property can accept any CSS color value.

1
  • 12
    This might work with CSS internal to an SVG image, but it doesn't work as CSS applied externally to an img element by the browser. Commented Jan 30, 2020 at 23:40
-2
    -webkit-filter: invert(100%); /* Safari/Chrome */
    filter: invert(100%) brightness(0%);

This is the easiet method

2

Not the answer you're looking for? Browse other questions tagged or ask your own question.