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:
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!
--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.
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!
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.