csscss-filters

CSS `filter`: fading an image to white by overlaying a white color


The CSS filter property has a number of filter functions.

I'm looking to "wash out" the image I'm applying this to, as if I was overlaying a solid white layer on top of the image and controlling its opacity. It should fade from the original image to a fully white image using a factor custom property (--fade: 0.5;) that can be animated.

At first glance, the brightness() filter seems like what I'd want. However this actually multiplies the chosen factor by each pixel, brightening the light pixels but keeping the already-dark pixels dark (when given a value above 1, or darkening the bright pixels if below 1).

I'm surprised by the lack of a filter which overlays a translucent white color over the top of the image. How can I still achieve this with a CSS filter property?

Original
Original image
CSS filter with brightness(1.5) CSS filter with brightness(1.9)
Image with filter at 1.5 Image with filter at 1.9
Desired (overlayed 50% opacity white) Desired (overlayed 90% opacity white)
Image with an overlayed 50% opacity white layer Image with an overlayed 90% opacity white layer

Solution

  • The code

    --factor: 0.5; /* Should range only between 0 and 1 */
    filter: invert(calc(var(--factor) / 2)) brightness(calc(1 + var(--factor)));
    

    This is achieved by combining the brightness() function with the invert() function as shown below. The trick is to get invert to reach 0.5 and brightness to reach 2 when the desired brightness is 100%. The result is a very close approximation of the desired filter which should be sufficient for producing the visually intended result, even when the --factor parameter is animated between 0 and 1.

    Solution at --factor: 0.5 Solution at --factor: 0.9
    Approximated solution at 0.5 Approximated solution at 0.9

    Why this works

    Observe that invert(0.5) produces a fully-gray image, where every pixel is halfway along its path towards its inverse, meaning they all meet at 0.5 (middle gray). Run the code snippet below for invert(factor / 2). At invert(1 / 2), the result of this operation is mapping the blacks, midtones, and whites all to middle gray.

    But we actually want to map the blacks, midtones, and whites to white, which is exactly double middle gray. Since brightness() just multiplies every pixel by its given value, we can double the pixels using brightness(2). Run the code snippet below for brightness(1 + factor).

    Combining both functions together, we first map each pixel along the path to middle gray, then double it to white.

    Interactive demo for comparison

    document.addEventListener("DOMContentLoaded", () => {
        const img = document.querySelector("img");
        const input = document.querySelector("input");
    
        const update = () => document.body.style.setProperty("--factor", input.value);
    
        update();
        input.addEventListener("input", update);
    });
    #invert:checked ~ div img {
        filter: invert(calc(var(--factor) / 2));
    }
    
    #brightness:checked ~ div img  {
        filter: brightness(calc(1 + var(--factor)));
    }
    
    #combined:checked ~ div img  {
        filter: invert(calc(var(--factor) / 2)) brightness(calc(1 + var(--factor)));
    }
    
    #ground-truth:checked ~ div::after {
        content: "";
        background: rgba(255, 255, 255, var(--factor));
        position: absolute;
        top: 0;
        bottom: 0;
        left: 0;
        right: 0;
    }
    
    div {
        display: inline-block;
        position: relative;
    }
    <label><code>factor</code>:</label>
    <input type="range" min="0" max="1" step="any" value="0.5" /><br />
    
    <input type="radio" name="choice" id="invert" value="invert" />
    <label for="invert"><code>invert(factor / 2)</code></label><br />
    
    <input type="radio" name="choice" id="brightness" value="brightness" />
    <label for="brightness"><code>brightness(1 + factor)</code></label><br />
    
    <input type="radio" name="choice" id="combined" value="combined" checked />
    <label for="combined"><code>invert(factor / 2) brightness(1 + factor)</code> (approximate filter)</label><br />
    
    <input type="radio" name="choice" id="ground-truth" value="ground-truth" />
    <label for="ground-truth">desired ground truth (correct filter)</label><br />
    
    <div>
    <img src="https://i.sstatic.net/k53TSb8R.png" alt="Test image" style="display: block" />
    </div>

    For fun: quantifying the approximation error

    For some fun math exploration, I've analyzed the error between the desired and approximated filters.

    The result is generally correlated with the linear interpolation of white over the image, but the equations are not exactly the same.

    The equation for the desired filter is:

    f(factor, pixel) = (pixel) - (pixel)(factor) + (factor)
    

    The equation for the approximated filter is:

    f(factor, pixel) = (pixel) + (0.5 - pixel)(factor)(factor) + (0.5)(factor)
    

    This approximation does technically have a small region where white values are clipped (shown above the gray ceiling plane below). A comparison of the correct and approximated filters, as functions of the chosen factor and the input pixel channel brightness, can be seen in this 3D graph: https://www.desmos.com/3d/ldxipd3j1n

    3D graph visualizing the error between the filter functions