Methods for Contrasting Text Against Backgrounds

It started with seeing a recent Pen of Mandy Michael's text effects demos. I'm a very visual creature, so the first thing I noticed was the effect, not the title (which clearly states how the effect was achieved). Instantly, my mind went "blend modes!", which turned out to be wrong.

The demo actually uses clip-path. First of all, the text is duplicated. We have black text below as the actual text content of the element and the white text above as the value of the content property (taken from a data attribute which gets updated via JS). These two are stacked one on top of each other (they completely overlap). Then the pseudo-element with the white text above gets clipped to the shape of the black dress.

However, this means we need to change the clipping path if we change the image and, at this point, it's anything but easy to figure out polygonal clipping paths with a lot of points via dev tools (which is why having something like Benett Feely's Clippy with two-way editing directly in dev tools would be immensely useful). So I decided to give my initial idea - blend modes - a try.

Let's say we have a heading in a contentEditable1 container with a black and white image (well, grayscale) background. The HTML structure is as follows:

<header>
  <h2 contentEditable role='textbox' aria-multiline='true'>And stay alive</h2>
</header>

We set the background-image on the header container, we give the h2 white text and set its mix-blend-mode to difference or exclusion. The relevant CSS is below:

header { background: url(black-and-white-image.jpg) }

h2 {
  color: white;
  mix-blend-mode: difference;
}

I can't really understand blend modes or normally see a difference between difference and exclusion, but according to MDN, they're pretty much the same, with exclusion having less contrast. What I can understand in this situation is that having white text over the image gives us a result that's the image inverted where the text overlaps it. For simple black and white images, black in the original image becomes white where we have white text above it and white in the original image becomes black where we have white text above it.

The result can be seen in the following Pen:

See the Pen by thebabydino (@thebabydino) on CodePen.

Mission accomplished! Both the text and the image can be changed and the effect is preserved without the need for any JavaScript or for any changes to the CSS.

But I instantly found that there was another itch to scratch: what happens if the image isn't just black and white? Well, let's try that! Turns out the result actually looks pretty good:

See the Pen by thebabydino (@thebabydino) on CodePen.

However, the text isn't grayscale anymore and setting filter: grayscale(1) doesn't change anything. Does any filter value work? Well, tried drop-shadow() next to try to find an answer to this question. And the answer is that, yes, drop-shadow() works, but the way it works - the shadow being blended with the header background2 - provides a clue about why grayscale() didn't change a thing: filters are applied before blending, our text is white and the output of grayscale() in this case is identical to its input (white in, white out). And, since nothing changes before the blending, its result is the same.

See the Pen by thebabydino (@thebabydino) on CodePen.

This probably shouldn't have been a surprise. I've ran into issues caused by the order in which properties are applied before.

For example, filter is also applied before clip-path, so if we clip an element to a non-rectangular shape and we want to have a drop shadow on it, tough luck, setting the filter on the same element doesn't give the expected result. This is because the rectangular element gets the filter applied, so we have a drop shadow around its rectangular box and only after that gets clipped to the shape specified via clip-path. This is always the order these two get applied in, regardless of the order you set them in the CSS.

Diagram. A box representing an element (left) 
has a CSS filter applied to become a box with a box-shadow (center) then has a clip-path applied to 
become a star with no shadow (right).
Diagram of how browsers apply filter and clip-path when they're set on the same element.

The way to solve the filter + clip-path problem is to set the filter on a parent element with nothing visible except the clipped child. The "nothing visible" part is important because, if the parent has some visible text or borders, they'll get the drop shadow as well, while if it has a background, then the whole background area gets the shadow.

See the Pen by thebabydino (@thebabydino) on CodePen.

However, this solution doesn't work for the filter + mix-blend-mode problem as well. Wrapping our h2 into a div and setting filter on that div breaks the blending effect.

See the Pen by thebabydino (@thebabydino) on CodePen.

What's worse is that I can't seem to think of anything I could do to get around this problem. There might be a way of doing it by duplicating the text and using a luminosity blend mode, but, as mentioned before, I don't really understand blend modes and I haven't been able to get that right.

But maybe we could try a different approach altogether, one that doesn't use blending. All the playing with filters gave me the idea that we could apply the image to the text, then apply an invert() filter, to which we could chain grayscale(), contrast() and more.

With this method using background-clip: text, we also have the advantage of a cross-browser solution, since Firefox and Edge have now implemented this too.

The way we go about this is the following: we make sure the header and the h2 have identical backgrounds and that these backgrounds perfectly overlap. Then we set color: transparent on the h2 and clip its background to text. The final step is to set filter: invert(1) on the h2. The relevant CSS3 is as follows:

h2 {
  background: inherit;
  background-clip: text;
  color: transparent;
  filter: invert(1);
}

The result can be seen in the following Pen:

See the Pen by thebabydino (@thebabydino) on CodePen.

It looks like before, except it's using a different method and now we can chain more functions to the filter property. For example, we can make the text grayscale and up its contrast:

filter: invert(1) grayscale(1) contrast(9)

See the Pen by thebabydino (@thebabydino) on CodePen.

Tweaking the filter value is also how we add a text shadow. While in the first case (using mix-blend-mode) we can simply set the text-shadow property (and the shadow also gets blended with the background), doing so in the second case breaks things:

See the Pen by thebabydino (@thebabydino) on CodePen.

Fortunately, we can chain drop-shadow() to our filter.

See the Pen by thebabydino (@thebabydino) on CodePen.

The Pen below shows the two methods described in this article. On the left, we have the mix-blend-mode method and on the right, we have the background-clip and filter method (with the extra grayscale and contrast components). The text is editable and the thumbnails allow for changing the background-image:

See the Pen by thebabydino (@thebabydino) on CodePen.

So, if we pit these methods against each other, which is better?

When don't go into aesthetics because it's a subjective matter and it probably differs from case to case and even from mood to mood.

As far as basic support is concerned, the last method gets an advantage since it's supported in Edge, while mix-blend-mode isn't (but if you want it in Edge, please vote for it because your feedback does matter). Mandy's original example uses clip-path, another very useful feature, which sadly doesn't work in Edge either (you can vote for it here).

When it comes to selecting text, the mix-blend-mode method works perfectly and looks the best across the browsers it's supported.

The editable text, made to contrast with the background image using the mix-blend-mode method, shown selected in its entirety. The selection background is blended with the image underneath.
Selecting text when using the mix-blend-mode method

Using the clip-path method, the selection looks clean and the text remains readable, but it seemed to stumble in Firefox while I was over the pseudo-element text. Fortunately, this problem turned out to have an easy fix: setting pointer-events: none on the pseudo-element.

Animated gif. The editable text, made to contrast with the background image using the clip-path method, gets selected in Firefox. The selection stumbles where the copy of this text, generated via a pseudo-element, overlaps the original. Setting pointer-events: none on the pseudo-element fixes this problem.
Selecting text in Firefox when using the clip-path method

As for the last method, the selection can look ugly and can make the text harder to read. Not to mention that the drop shadow ends up being applied to the whole selection rectangle in this case, not just to the actual text.

The editable text, made to contrast with the background image using the background-clip: text + filter method, shown selected in its entirety. The whole selection box has the filter effect applied, not just the text, so the selection background is grayscale and the drop-shadow is now on the selection box, not just on the text itself.
Selecting text when using the background-clip: text + filter method

Changing the text also gets awkward in Firefox with the last method, the screen getting "dirty" as I type new text.

Animated gif. The editable text, made to contrast with the background image using the background-clip: text + filter method, gets edited in Firefox. As you write, the text becomes clipped and garbled, overlapping itself.
Changing text in Firefox when using the background-clip: text + filter method

The clip-path method also had a problem with changing text in Firefox at first: trying to start editing from the middle of the pseudo-element text didn't work. Fortunately, setting pointer-events: none on the pseudo-element fixes this as well.

Animated gif. The editable text, made to contrast with the background image using the clip-path method, gets edited in Firefox. The cursor cannot be placed to start editing from the part where the copy of this text, generated via a pseudo-element, overlaps the original. Setting pointer-events: none on the pseudo-element fixes this problem.
Changing text in Firefox when using the clip-path method

As far as flexibility in terms of how we can alter the text goes, the last method fares best because chaining filter functions can take us a long way.

At the end of the day, which is better ends up depending on the particular use case. What browsers should be supported? Does the image change? Does the text change? How should the text be visually altered?


1 contentEditable isn't accessible in all browsers by default, so we need to provide semantics to fix that.

2 When experimenting with blend modes, be aware of the legibility issues they can produce, particularly regarding contrast. WCAG 2.0 states that, unless the text forms part of purely decorative imagery, it should pass a minimum contrast threshold. Tools like Color ContrastAnalyzer can help you meet that requirement.

3 Prefixes are omitted for brevity, but background-clip still needs the -webkit- prefix for WebKit browsers (Firefox and Edge have implemented it unprefixed, though they both support it with the -webkit- prefix as well, probably because it has already been used to death only with that prefix) and this prefix needs to be added manually/ via a preprocessor mixin, which is something that I normally don't encourage, but, in this particular case, auto-prefixing via Autoprefixer or Prefixfree doesn't happen (Prefixfree works via feature detection, which doesn't catch properties such as background-clip, filter or clip-path and this is the Autoprefixer issue). As for filter, it's now supported unprefixed in all current desktop browsers and Autoprefixer can prefix it anyway.