Dynamic text color contrast based on background lightness with CSS/SVG filters

One of my CSS pet peeves has been components where I want to dynamically have either a white or black text color based on the background lightness:

Boxes with different background colors

Traditionally this has been done either with JavaScript or with a preprocessor like SCSS, and more recently by breaking the color components down into HSL as CSS variables and (ab)using calc() to calculate the contrast.

I found a couple of novel ways today, though! First detailed in this toot, I figured I should publish a proper post about it.

1. Using CSS filters

Play with the sliders!

120°
50%
50%
Assuming --bg-color contains your background color:
span {
    color: var(--bg-color);
    filter: invert(1) grayscale(1) brightness(1.3) contrast(9000);
    mix-blend-mode: luminosity;
    opacity: 0.95;
}

This will invert the text color, grayscale it, increase its brightness, and increase its contrast. The mix-blend-mode is there to make sure the text color doesn't affect the background color. The opacity is there to make sure the text color doesn't become too bright.

This will turn to black around #AAAAAA. You can adjust brightness lower if you want it to turn to black earlier. mia made a codepen to play around with this.

Drawbacks: There is noticeable fringing right at the edge of the color switch. This happens as the contrast gets slammed to the maximum, and the antialiased colors around the edges of the font get to pick from a semi-random binary choice of black or white depending on the geometry. The browser doesn't do a great job of antialiasing that either.

Fringing around the text

Conclusion: This works well for situations where you are in some sort of control over the color! It's a bit of a hack, but a stupid fun one, the best kind.

2. Using SVG filters

Play with the sliders!

120°
50%
50%

To get rid of the fringing, you can create a SVG filter that gives us the option to dilate the edges of the text color. This will make the antialiased colors around the edges of the font pick from a larger area, and the browser will do a better job of antialiasing that.

First, add this SVG to your markup. We need to be able to reference the filter inside it with the ID #bwFilter from CSS, so it needs to be in the same document.

<svg xmlns="http://www.w3.org/2000/svg" version="1.1" height="0" style="position:absolute; height:0;">
    <defs>
        <filter id="bwFilter" color-interpolation-filters="sRGB">
            <!-- Convert to grayscale based on luminance -->
            <feColorMatrix type="matrix"
                values="0.2126 0.7152 0.0722 0 0
                        0.2126 0.7152 0.0722 0 0
                        0.2126 0.7152 0.0722 0 0
                        0 0 0 1 0"/>
            <!-- Expand edges slightly to clean up any fringing -->
            <feMorphology operator="dilate" radius="2"/>
            <!-- Apply the threshold to determine if the color should be black or white -->
            <feComponentTransfer>
                <feFuncR type="linear" slope="-255" intercept="128"/>
                <feFuncG type="linear" slope="-255" intercept="128"/>
                <feFuncB type="linear" slope="-255" intercept="128"/>
            </feComponentTransfer>
            <!-- Composite step to clean up the result -->
            <feComposite operator="in" in2="SourceGraphic"/>
        </filter>
    </defs>
</svg>

Using it simple; assuming --bg-color contains your background color:

span {
    color: var(--bg-color);
    filter: url(#bwFilter);
}

Enjoy fringe-less text contrast! Here's a codepen to play around with this.