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:

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.

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.

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 beforeclip-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 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.

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:

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:

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:

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.

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.

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.

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.

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.

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?

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

2When 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.

3Prefixes 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.