A Deep Dive into the Inline Background Overlap Problem

Ana Tudor Ana Tudor on

tweet by Lucas Bonomi got me thinking about this problem: how to get a semitransparent background following some inline text with padding, but without the overlap problem that can be seen in the image below.

Screenshot showing three lines of text, middle aligned, each with its own semitransparent background and padding. The padding on each line leads to intersection, and where we have intersection, the semi-transparent background becomes more opaque. The challenge is to get this result without the increase in alpha in the intersection areas.
the problem at hand: the overlapping parts appear darker because of the layered opacity

Temani Afif had already suggested using an SVG filter solution, and that was my first instinct too.

While the initial problem has a pretty simple solution, more complex variations lead me down a deep rabbit hole and I thought the journey was worth sharing in an article.

The initial problem and exact particular solution

We start with some middle-aligned text wrapped inside a p and a span. The span gets padding, border-radius, and a semi-transparent background.

p > span {
  padding: .25em;
  border-radius: 5px;
  background: rgb(0 0 0/ var(--a, .7));
  color: #fff;
  box-decoration-break: clone
}Code language: CSS (css)

We’re also setting box-decoration-break: clone so that each wrapped line gets its own padding and corner rounding (this is a very neat CSS feature that’s worth looking into if you’re not familiar with it).

The result of the above code looks as follows:

Screenshot showing four lines of text, middle aligned, each with its own semitransparent background and padding. The padding on each line leads to intersection, and where we have intersection, the semi-transparent background becomes more opaque. This is basically the same as the problem illustrated by the challenge image.
what the above CSS gives us: the overlap problem

This is pretty much the same as the screenshot Lucas posted, so let’s see how we can fix it with an SVG filter!

The first step is to make the background of the span opaque by setting --a to 1. This gets rid of the overlap increasing alpha problem because there is no more transparency. To restore that transparency, we use an SVG filter. We’ll get to that in a moment, but for now, these are the styles we add:

/* same other styles as before */
p {
  --a: 1;
  filter: url(#alpha)
}Code language: CSS (css)

The SVG filter needs to live inside an svg element. Since this svg element only contains our filter and no actual SVG graphics to be displayed on the screen, it is functionally the same as a style element, so there’s no need for it to be visible/ take up space in the document flow.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='alpha'>
    <!-- filter content goes here -->
  </filter>
</svg>Code language: HTML, XML (xml)
svg[height='0'][aria-hidden='true'] { position: fixed }Code language: CSS (css)

The first primitive, feComponentTransfer, takes the SourceAlpha (basically, the filter input, with the RGB channels of all pixels zeroed, all pixels become black, but keep their alpha) as input (in) and scales it to the desired alpha, basically giving us the semitransparent version of the shape of the span background. This is because the input alpha is 1 within the span background area and 0 outside it. Multiplying the desired alpha with 1 leaves it unchanged, while multiplying it with 0… well, zeroes it.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='alpha'>
    <feComponentTransfer in='SourceAlpha' result='back'>
      <feFuncA type='linear' slope='.7'/>
    </feComponentTransfer>
  </filter>
</svg>Code language: HTML, XML (xml)

We’ve also named the result of this primitive back so we can reference it later in primitives not immediately folowing this particular feComponentTransfer one.

Screenshot showing the semitransparent background for the same lines of text as before, but without the actual text and without any increase in alpha in the intersection areas.
result of the first filter step: the semitransparent black background

Now we have the semi-transparent multi-line span background with no increase in alpha in the overlap areas. But we still need to get the text and add it on top of it.

Next, we have a feColorMatrix primitive that uses the green channel as an alpha mask (the second value on the last row of the matrix is the only non-zero one) and maxes out (sets to 100%) all RGB channels of the output (last column, first three rows), basically painting the output white with an alpha equal to the input green channel value. This means the result is full transparency where the input’s green channel is zero (everywhere outside the white text) and opaque white where it’s maxed out (just for the white text).

<svg width="0" height="0" aria-hidden="true">
  <filter id="alpha">
    <feComponentTransfer in="SourceAlpha" result="back">
      <feFuncA type="linear" slope=".7" />
    </feComponentTransfer>
    <feColorMatrix
      in="SourceGraphic"
      values="0 0 0 0 1 
              0 0 0 0 1 
              0 0 0 0 1 
              0 1 0 0 0"
    />
  </filter>
</svg>
Code language: HTML, XML (xml)

Note that by default, the inputs of any primitives other than the very first one in the filter get set to the result of the primitive right before, so for this feColorMatrix primitive we need to explicitly set the input in to SourceGraphic.

Also note that there’s a reason behind using the green channel to extract the text. This is because when using Chrome and a wide gamut display, we may hit a bug which causes feColorMatrix to find for example red in what’s 0% red, 100% green and 0% blue. And it’s not just that, but extracting the red channel out of 100% red, 0% green and 0% blue doesn’t give us 100% red, but a lower value.

To get an idea of just how bad the problem is, check out the comparison screenshot below – everything should have all channels either maxed out or zeroed (like on the left), there should be no in betweens (like on the right).

Comparative screenshots for various tests: extracting just the individual channels, their negations, unions, intersections, XORs, as well as extracting them as alpha masks. On the left, we have the extected result: extract 100% out of each channel that's maxed out, 0% out of those zeroed. On the right (wide gamut case), we however find red, green, blue where these channels have been zeroed.
expected vs. wide gamut problem (live test)

After a bunch of tests, it results the problem is less noticeable when using the green channel (compared to when using the blue or red channels), so we’re trying to limit this bug on the hardware where it’s possible to hit it.

We now have just the white text:

Screenshot showing just the white text for the same lines as before, no background at all.
result of the second filter step: just the white text

The final step is to place the semi-transparent black background underneath it (in2 specifies the bottom layer):

<svg width="0" height="0" aria-hidden="true">
  <filter id="alpha">
    <feComponentTransfer in="SourceAlpha" result="back">
      <feFuncA type="linear" slope=".7" />
    </feComponentTransfer>
    <feColorMatrix
      in="SourceGraphic"
      values="0 0 0 0 1 
              0 0 0 0 1 
              0 0 0 0 1 
              0 1 0 0 0"
    />
    <feBlend in2="back" />
  </filter>
</svg>
Code language: HTML, XML (xml)

I see feMerge often used for this, but here we only have two layers, so I find feBlend (with the default mode of normal which just places the top layer in over the bottom layer in2) a much simpler solution.

Note that we’re not specifying in explicitly because, by default, it’s the result of the previous primitive, the feColorMatrix. This is also why we didn’t bother with setting the result attribute like we did for the first primitive, the feComponentTransfer one because the output of this feColorMatrix primitive only gets fed automatically into the in input of the final primitive and nowhere else after that.

Cool, right?

Screenshot showing four lines of text, middle aligned, each with its own semitransparent background and padding. The padding on each line leads to intersection, but now there is no more increase in alpha in the intersection areas.
the desired result (live demo)

Expanding the problem scope

I thought this was a neat trick worth sharing, so I posted about it on social media, which lead to an interesting conversation on Mastodon.

Patrick H. Lauke pointed me to a CodePen demo he had made a few years back, higlighting a related problem I wasn’t hitting with the quick demo I had shared: the background of the later lines covering up the text of the ones right before them.

My demo wasn’t hitting this problem because I had tried to stay reasonably close to the initial challenge screenshot, so I hadn’t used a big enough padding to run into it. But let’s say we increase the padding of the span from .25em to .5em (and also remove the filter to make the problem more obvious).

Screenshot showing four lines of text, middle aligned, each with its own fully opaque black background and padding. The padding on each line leads to its red background partly covering the white text of the line above.
the bigger padding problem

The simplest case: separate spans, opaque backgrounds, black/ white text

We first consider the case when we only have separate words wrapped in spans with opaque backgrounds and the text is either black or white (or at least very close). In this very simple case, a properly set mix-blend-mode on span elements (darken for black text, lighten for white) suffices, there’s no need for an SVG filter.

Screenshot showing a multi-line paragraph with isolated words highlighted by being wrapped in spans that get a background contrasting with both the backdrop and the text. Both the light theme case (left, black text, white backdrop, pink highlight) and the dark theme case (right, white text, black backdrop, crimson highlight) are included. The highlights expand out quite a bit outside the words they're meant to pop, overlapping some of the neighbouring ones, but they always show behind the text.
isolated spans on opaque contrasting background

Both darken and lighten value work on a per pixel, per channel basis. For each pixel of the input, they take either the minimum (darken) or the maximum (lighten) channel value betwen the two blended layers to produce the result.

Black always has all channels smaller or at most equal to those of anything else. So when we blend any background layer with black text using the darken blend mode, the result always shows the black text where there is overlap because the 0%-valued channels of the black text are always the result of the minimum computation.

White always has all channels bigger or at most equal to those of anything else. So when we blend any background layer with white text using the lighten blend mode, the result always shows the white text where there is overlap because the 100%-valued channels of the white text are always the result of the maximum computation.

Now this works fine as it is when we don’t have any backdrop behind or when the backdrop is either white for black text or black for white text. In other cases, for example if we have a busy image behind, things don’t look as good as the span elements also get blended with the image backdrop.

Screenshot showing the same multi-line paragraph with isolated words highlighted by being wrapped in spans that get a background contrasting with both the backdrop and the text in both the light and dark theme case. However, now the backdrop is a busy image and we can see how the highlights blend with it, instead of just being placed on top and covering it.
isolated spans on busy background problem

Luckily, the fix is straightforward: we just need to set isolation: isolate on the parent paragraph!

Screenshot showing the same multi-line paragraph with isolated words highlighted by being wrapped in spans that get a background contrasting with both the backdrop and the text in both the light and dark theme case. The backdrop is now a busy image, lighter or darker depending on the theme, but the highlights are simply on top, they don't get blended with it anymore.
isolated spans on busy background solution (live demo)

Slightly more complex: long wrapping span, opaque background, black/ white text

In this case, the mix-blend-mode solution isn’t enough anymore because the point of it was to blend the span background with the text of the parent paragraph that gets covered. But now it’s the span‘s own text that gets covered by the background of its next line.

Screenhot showing a multi-line paragraph with a long portion highlighted by being wrapped in a span that gets a background contrasting with both the backdrop and the text. Both the light theme case (left, black text, light image backdrop, pink highlight) and the dark theme case (right, white text, dark image backdrop, crimson highlight) are included. The highlights expand out quite a bit outside the portion they're meant to pop, overlapping some of the neighbouring words and lines, yet they always show behind the text of the paragraph around. Unfortunately, that's not enough, as they are painted above the text on the previous line that's also wrapped in the same span.
long wrapping span problem in spite of mix-blend-mode

To get around this, we wrap the entire span in another span and set the padding and background only on the outer span (p > span). This causes the black/white text of the inner span as well as that of the paragraph around the spans to get blended with the outer span background.

Screenhot showing a multi-line paragraph with a long portion highlighted by being wrapped in a span that gets a background contrasting with both the backdrop and the text. Both the light theme case (left, black text, light image backdrop, pink highlight) and the dark theme case (right, white text, dark image backdrop, crimson highlight) are included. The highlights expand out quite a bit outside the portion they're meant to pop, overlapping some of the neighbouring words and lines, yet they always show behind the text of the paragraph around and the text on the previous line that's also wrapped in the same span.
long wrapping span nesting solution (live demo)

If you’ve checked the above demo in Firefox, you may have noticed that it doesn’t work. This is due to bug 1951653.

In the particular case when the entire text in the paragraph is wrapped in a span, we can avoid the Firefox bug by setting the mix-blend-mode property only on the inner span (span span).

However, in the case above, where we also have paragraph text outside the outer span too, this unfortunately still leaves us with the problem of that text before the long span getting covered by the background of the next span line.

Screenhot showing a multi-line paragraph with a long portion highlighted by being wrapped in a span that gets a background contrasting with both the backdrop and the text. Both the light theme case (left, black text, light image backdrop, pink highlight) and the dark theme case (right, white text, dark image backdrop, crimson highlight) are included. The highlights expand out quite a bit outside the portion they're meant to pop, overlapping some of the neighbouring words and lines, yet they always show behind text on the previous line that's also wrapped in the same span. Unfortunately, not also below the text of the paragraph before this long rapping span.
Firefox workaround not good enough if there’s paragraph text before the long wrapping span

The most complex case: transparent background where neither the text nor the background are black/white

In this case, the blending solution isn’t enough anymore and we need an SVG filter one.

Going back to our original demo, we need to apply the solution from the previous case: wrap the span in another, set the padding and background only on the outer one (p > span), blend only the inner span element with the outer one to ensure our solution works cross-browser (since we have white text, we use the lighten mode) and prevent blending with anything outside the containing paragraph p by setting isolation: isolate on it.

p {
  color: #fff;
  isolation: isolate;
  filter: url(#alpha)
}

p > span {
  padding: .5em;
  border-radius: 5px;
  background: #000;
  box-decoration-break: clone;
	
  span { mix-blend-mode: lighten }
}Code language: CSS (css)
Screenshot showing four lines of text, middle aligned, each with its own semitransparent background and padding. The padding on each line leads to intersection, not just between padding areas on adjacent lines, but also between padding and text, but now there is no more increase in alpha in the padding intersection areas and all the background is always behind all the text.
the desired result in the bigger padding case (live demo)

But what we want here is to move away from black/ white text and background, so let’s see how to do that.

Set RGBA values in the SVG filter

If we wanted to have a background that’s not semi-transparent black, but a semi-transparent dark blue, let’s say rgb(25 25 112) (which can also be written as rgb(9.8% 9.8% 43.9%)), as well as gold-orange text, let’s say rgb(255 165 0) (which can also be written as rgb(100% 64.7% 0%)), then we use feColorMatrix as the first primitive as well and alter the final column values on the first three matrix rows for both the first matrix giving us the background and the second one giving us the text to use the decimal representation of the three percentage RGB values:

<svg width="0" height="0" aria-hidden="true">
  <filter id="alpha" color-interpolation-filters="sRGB">
    <feColorMatrix
      values="0 0 0 0  .098 
              0 0 0 0  .098 
              0 0 0 0  .439 
              0 0 0 .7 0"
      result="back"
    />
    <feColorMatrix
      in="SourceGraphic"
      values="0 0 0 0 1 
              0 0 0 0 .647 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feBlend in2="back" />
  </filter>
</svg>Code language: HTML, XML (xml)

Other than the id, we’ve now also set another attribute on the filter element. We aren’t going into it because I don’t really understand much about it, but just know that this attribute with this value needs to be added on any SVG filter that messes with the RGB channels. Otherwise, the result won’t be consistent between browsers (the default is linearRGB in theory, but only the sRGB value seems to work in Safari) and it may not match expectations (the sRGB value is the one that gives us the result we want). Previously, having just white text on a black background, we didn’t really need it and it was safe to skip it, but now we have to include it.

Screenshot showing four lines of golden text, middle aligned, each with its own semitransparent dark blue background and padding. The padding on each line leads to intersection, not just between padding areas on adjacent lines, but also between padding and text, but there is no increase in alpha in the padding intersection areas and all the background is always behind all the text.
golden text on dark blue background using the method of setting the RGB values in the SVG filter (live demo)

The problem with this solution is that it involves hardcoding the RGBA values for both the span background and text in the SVG filter, meaning we can’t control them from the CSS.

Let’s try another approach!

Set RGBA values upstream of the SVG filter

First, we set them as custom properties upstream of the svg:

body {
  --a: .5;
  --back-c: rgb(25 25 112/ var(--a));
  --text-c: rgb(255 165 0)
}Code language: CSS (css)

Then we modify the filter a bit. We use SourceAlpha to give us the background area, though we still extract the text area via a feColorMatrix primitive and save it as text, but this time we don’t care about the RGB values, we won’t use them anyway. We also flood the entire filter area with --back-c and --text-c (using feFlood), but then, out of the entire area, we only keep what’s at the intersection (operator='in' of feComposite) with the SourceAlpha and text areas respectively. Finally, we stack these intersections (via feBlend), with the text on top.

<svg width="0" height="0" aria-hidden="true">
  <filter id="alpha" color-interpolation-filters="sRGB">
    <feFlood flood-color="var(--back-c)" />
    <feComposite in2="SourceAlpha" operator="in" result="back" />
    <feColorMatrix
      in="SourceGraphic"
      values="0 0 0 0 0 
              0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
      result="text"
    />
    <feFlood flood-color="var(--text-c)" />
    <feComposite in2="text" operator="in" />
    <feBlend in2="back" />
  </filter>
</svg>Code language: HTML, XML (xml)

This allows us to control both the text and background from the CSS.

However, the values of --back-c and --text-c are those of the feFlood primitive, not those on the element the filter applies to. So for any different text or background, we need to have a different filter.

If that’s difficult to grasp, let’s say we want two different options, the same golden-orange text on a dark blue background and also dark blue text on a pink background.

body {
  --a: .7;
	
  --back-c-1: rgb(25 25 112/ var(--a));
  --text-c-1: rgb(255 165 0);
	
  --back-c-2: rgb(255 105 180/ var(--a));
  --text-c-2: rgb(25 25 112);
	
  --back-c: var(--back-c-1);
  --text-c: var(--text-c-1)
}Code language: CSS (css)

Now we can change --back-c and --text-c on the second paragraph:

p:nth-child(2) {
  --back-c: var(--back-c-2);
  --text-c: var(--text-c-2)
}Code language: CSS (css)

But changing these variables on the second paragraph doesn’t do anything for the result of the SVG filter applied to it because the values for --back-c and --text-c that get used by the filter are always those set upstream from it on the body.

the problem seen in DevTools

Unfortunately, this is just how things are for SVG filters, even though CSS ones don’t have this limitation, like the comparison below shows.

Screenshot illustrating the above. `--c` is set to `orangered` on the body and this is the value used for the drop shadow created by the SVG filter, regardless of what value `--c` has on the element the SVG filter is applied on. By contrast, when using a CSS drop shadow filter, the value of `--c` is the one set on the element the filter is applied on.
CSS vs. SVG drop-shadow filter using a variable for flood-color (live demo)

Set RGB values in the CSS, fix alpha in the SVG filter

Amelia Bellamy-Royds suggested a feComponentTransfer approach that allows setting the palette from the CSS and then using the SVG filter only to take care of the increase in alpha where there is overlap.

What Amelia’s filter does is use feComponentTransfer to preserve the alpha of everything that’s fully transparent (the area outside the span) or fully opaque (the text), but map a bunch of alpha values in between to the desired background alpha a. This should also catch and map the background overlap alpha (which is a + a - a*a = 2*a - a*a – for more details, see this Adventures in CSS Semi-Transparency Land article) to a.

This is a very smart solution and it seems to work really well for this particular background and text case as well as for similar cases. But there are still issues, points where it breaks.

First off, if we increase the alpha to something like .75, we start seeing an overlap.

Screenshot showing four lines of golden text, middle aligned, each with its own semitransparent dark blue background and padding. The padding on each line leads to intersection, and where we have intersection, the semi-transparent background becomes more opaque.
overlap problem becoming visible when alpha is bumped up to .75

My first instinct was to do what Amelia also suggests doing in the comments to her version – increase the number of intervals as the alpha gets closer to the ends of the [0, 1] interval.

Since I’m using Pug to generate the markup anyway, I figured this would be a good way to first measure how large the base intervals would need to be – and by that I mean the minimum between the distance between the ends of the [0, 1] interval and the desired alpha as well as the overlap alpha.

We’re excluding 2*a - a*a and 1 - a from the minimum computation since a is subunitary, so a is always bigger than a*a, which results in a being always smaller than 2*a - a*a = a*(2 - a), which also results in 1 + a*a - 2*a being smaller than 1 - a.

Then we get how many such base intervals u we could fit between 0 and 1, round up this number (n) and then generate the list of alpha values (for tableValues) which remains 0 and 1 at the ends, but is set to a everywhere in between.

- let u = Math.min(a, 1 + a*a - 2*a);
- let n = Math.ceil(1/u);
- let v = new Array(n + 1).fill(0).map((_, i) => i*(n - i) ? a : i/n)

feFuncA(type='table' tableValues=v.join(' '))Code language: HTML, XML (xml)

This does indeed fix the background overlap problem for any alpha, though it still means we need different filters for different alphas. Here is what gets generated for a few different alpha values:

<!-- a = .8 -->
<feFuncA type='table' 
         tableValues='0 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 1'/>

<!-- a = .75 -->
<feFuncA type='table' tableValues='0 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 1'/>

<!-- a = .65 -->
<feFuncA type='table' tableValues='0 .65 .65 .65 .65 .65 .65 .65 .65 1'/>

<!-- a = .5 -->
<feFuncA type='table' tableValues='0 .5 .5 .5 1'/>

<!-- a = .35 -->
<feFuncA type='table' tableValues='0 .35 .35 1'/>

<!-- a = .2 -->
<feFuncA type='table' tableValues='0 .2 .2 .2 .2 1'/>

<!-- a = .1 -->
<feFuncA type='table' tableValues='0 .1 .1 .1 .1 .1 .1 .1 .1 .1 1'/>

<!-- a = .05 -->
<feFuncA type='table' 
         tableValues='0 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 1'/>Code language: HTML, XML (xml)

We also have another bigger problem: due to font anti-aliasing, the feComponentTransfer messes up the text for lower value alphas and the lower the value, the worse the problem looks.

Font anti-aliasing makes the edge pixels of text semi-transparent in order to avoid a jagged, pixelated, ugly, even broken look. For comparison, below is the same text without vs. with anti-aliasing, at normal size and scaled up 12 times:

The text "Pixels" without (left) vs. with anti-aliasing (right), in normal size (top) and scaled up 12x times (bottom). The normal size text looks a bit rough in the no anti-aliasing version at normal size, whereas its anti-aliased version looks smooth. On zoom, we can see this is due to the no-anti-aliasing version having only fully opaque and fully transparent pixels, whereas the other version has its edges smoothened by semi-transparent pixels with various alpha levels.
without vs. with anti-aliasing

Those semi-transparent font edge pixels placed on top of the semi-transparent background also give us semi-transparent pixels. At the same time, our filter maps the alpha of more and more of the semi-transparent pixels of the input to the desired background alpha a as this a nears the ends of the [0, 1] interval. As a nears 0, then almost all semi-transparent edge pixels get this very low a alpha, making them much more transparent than they should be and causing an eroded look for our text.

illustrating the problem caused by anti-aliasing (live demo)

I guess a simple fix for that would be to only map to the desired alpha a the smallest number of alpha points possible and let all others keep their initial alpha. This would mean that the first alpha point we map to the desired alpha a is equal to it or the nearest smaller than it, while the last one is equal to the overlap alpha 2*a - a*a or the nearest bigger than it.

For example, if the desired alpha a is .2, then the overlap alpha is .2 + .2 - .2*.2 = .36. The base interval u is .2n is 1/.2 = 5, so we generate n + 1 = 6 alpha points:

0 .2 .4 .6 .8 1

If before we mapped all those between 0 and 1 to the desired alpha .2, now we only map to the desired alpha a, those loosely matching the [.2, .36] interval – that is, .2 and .4:

0 .2 .2 .6 .8 1

In general, that means our values array would become:

- let v = new Array(n + 1).fill(0);
- v = v.map((_, i) => (i*(n - i) && (i + 1)/n > a && (i - 1)/n < a*(2 - a)) ? a : i/n);Code language: HTML, XML (xml)

Probably ensuring the values outside the interval mapped to a are evenly distributed would be the more correct solution, but this simpler trick also seems to work really well when it comes to fixing the text erosion problem.

testing a basic fix for the antialiasing problem (live demo)

But you may have noticed there’s still a problem and this is not an SVG filter one, it comes from the CSS.

To make it more obvious, let’s put result right next to what we got via the earlier method of seting the RGBA values from the SVG filter – can you see it?

setting RGBA values in SVG filter method vs. RGB in CSS plus alpha fixing via SVG filter method (live demo)

If you can’t spot it in the recording above, how about when we have a diagonal middle split in between the result we get when we bake into the filter all RGBA values and the result we get with this alpha fix method via feComponentTransfer?

Split comparison screenshot. The paragraph box is split into two triangles, lightly separated by a gap along the secondary diagonal. In the top left triangle, we have the result obtained using the method of hardcoding the RGBA values for both the text and the background into the SVG filter. In the bottom right one, we have the result obtained using the method of setting the RGBA values in the CSS and then using an SVG filter to fix the overlap alpha to be the desired background alpha as well. Both use a background alpha of .85 and in his case, it looks like the text using the second method is a bit more faded.
split comparison

It’s pretty subtle here, but if you think it looks like this latest method is making the text a bit more faded, particularly at higher alpha values, you’re right.

This is because the blending fix for the background overlapping text problem results in the text color not being preserved. This was precisely why we switched from a blending-only solution to an SVG filter one in the case when the text isn’t black or white (or close enough and the particular choice of text and background preserves the text post-blending exactly as it was set).

A lot of text and background combinations don’t make this very obvious because, in order to have a good contrast ratio, we often need either the text or the background behind it to be very dark or very bright – which means there’s a chance all three RGB channels of the text are either below or above the corresponding RGB channels of the background, or even if one of the channels is deviating on the other side, it’s not deviating enough to make a noticeable difference. But sometimes we can still see there’s a problem, as illustrated by the interactive demo below, which allows changing the palette.

All of these palettes were chosen to have a good contrast ratio. Even so, there is some degree of text fading for all of them. And while it’s not easy to spot that for the first five, it’s way more noticeable for the second to last one and almost impossible to miss for the final one.

Let’s take the second to last one, which uses a lighter blue than our initial palette, so it has a somewhat lower contrast making the problem more obvious. The higher the alpha gets, what should be golden text on a semitransparent deep blue background looks more pink-ish. This is due to the text being rgb(100% 74.51% 4.31%) and the background being rgb(22.75% 21.18% 100%) (we leave out the transparency for now and assume the alpha is 1). Blending these using the lighten blend mode means taking the maximum value out of the two for each channel – that is, 100% (max(100%, 22.75%)) for the red channel, 74.51% (max(74.51%, 21.18%)) for the green one and 100% (max(4.31%, 100%)) for the blue one. That means our text is rgb(100% 74.51% 100%), a light pink, which is different from the color value of rgb(100% 74.51% 4.31%) (golden) we’ve set.

particular case of the second to last palette

The final text and background combination makes the problem even more clear. The higher the alpha gets, what should be lime text on a semitransparent blue background looks more like aqua text. This is due to the text being rgb(0% 100% 0%) and the background being rgb(0% 0% 100%) (again, we leave out the transparency for now and assume the alpha is 1). Blending these using the lighten blend mode means taking the maximum value out of the two for each channel – that is, 0% (max(0%, 0%)) for the red channel, 100% (max(100%, 0%)) for the green one and 100% (max(0%, 100%)) for the blue one. That means our text is rgb(0% 100% 100%), so aqua, which is different from the color value of rgb(0% 100% 0%) (lime) we’ve set.

particular case of the final palette

So what now? Well, the one solution I’ve been able to find is to pass in the text and background shapes separate from the RGBA values used for them. I’ve tried approaching this in multiple ways and ended up hitting bugs in all browsers. Tiling bugs in Safari and Chrome, a weird Windows-specific bug in Firefox, the same wide gamut bug mentioned before in Chrome… bugs everywhere.

So now we’re not going through all of my failed experiments, of which there were many, we’re just looking at the one solution I’ve managed to get working reasonably well across various browser, OS and hardware combinations.

Set shapes and RGBA values in the CSS, pass them to the SVG filter via different channels/ alpha points

The shape of the span background and that of the text get passed to the SVG filter using the 1 alpha point. That means we have white text on black background, all opaque, so we can extract it in the SVG by mapping all alpha points except 1 to 0.

We pass the text and background RGB values using the .75 and .25 alpha points – this allows us to extract them in the SVG filter by mapping their corresponding alpha points to 1, while all other alpha points are 0.

Finally, we pass the alpha value to the SVG via the green channel, using the .5 alpha point. By mapping the .5 alpha point to 1, while all other alpha points get mapped to 0, we can extract in the SVG filter the desired background alpha value via the green channel value.

This means we have five alpha points (0.25.5.75 and 1), so we’re going to need to use five values for the tableValues attribute of feFuncA, all of them zeroed, except the one corresponding to the point we’re interested in and which we map to 1.

In order to do this, we first add an absolutely positioned, non-clickable pseudo on the p element. This pseudo has a border and two shadows (an outer one and an inset one) and is offset outwards (using a negative inset) to compensate for both the inset shadow and the border, so that there is no visible part of this pseudo intersecting the span background shape.

p {
  --a: 0.7;
  --text-c: rgb(255 165 0);
  --back-c: rgb(25 25 112);
  position: relative;

  &::after {
    position: absolute;
    inset: -2em;
    border: solid 1em rgb(0% calc(var(--a) * 100%) 0%/ 0.5);
    box-shadow: inset 0 0 0 1em rgba(from var(--text-c) r g b/ 0.75),
      0 0 0 1em rgba(from var(--back-c) r g b/ 0.25);
    pointer-events: none;
    content: "";
  }
}Code language: CSS (css)

The first shadow is an inset one using the desired text RGB value and a .75 alpha, which allows us to pass the RGB value to the SVG filter via the .75 alpha point. The second shadow is an outer one using the desired background RGB value and a .25 alpha, which allows us to pass the RGB value to the SVG filter via the .25 alpha point.

The border-color uses the desired span background alpha value on the green channel (we’re using the green channel due to the same Chrome wide gamut bug mentioned earlier in this article) and has a .5 alpha. This allows us to pass to the SVG filter the value of the desired span background alpha as the green channel value using the .5 alpha point.

The negative inset (-2em) is set to compensate for both the inset shadow (with a 1em spread) and for the border (with a 1em width) because it’s very important that none of the visible parts of the pseudo (the border and the box-shadow using the .25.5 and .75 alpha points) intersect the shape of the span background (using the 1 alpha point).

The pointer-events: none property is there in order to avoid any interference with the span text selection. We could have also used z-index: -1, since there is no intersection between the visible parts of the pseudo and the span background shape. Both of them do the job and in this case, it really doesn’t matter which we choose to use.

What we have so far definitely doesn’t look great, but… we’re getting there!

Screenshot showing the result of the above CSS. We have four lines of white text, middle aligned, each with its own black background and padding. Although the background of each line overlaps the text of the adjacent ones, the text is shown everywhere on top. Around these lines of text, without touhing them, we have three nested frames. The innermost one has an alpha of .75 and the RGB value we want for the text in the final version. The middle one has an alpha of .5 and has the red and blue channels zeroed, while the green one has the value of the desired background alpha. The outer one has an alpha of .25 and the RGB value we want for the background.
before applying any filter

Moving on to the filter, we start in a similar manner as before, by getting the opaque part. To do so, we preserve just just the fifth alpha point (1), while mapping all others to 0. Everything that intially has an alpha of 0 (transparent part inside the frames around the span shape), .25 (outermost dark blue frame), .5 (middle green frame) or .75 (innermost golden frame) becomes transparent.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='go' color-interpolation-filters='sRGB'>
    <feComponentTransfer result='opaque'>
      <feFuncA type='table' tableValues='0 0 0 0 1'/>
    </feComponentTransfer>
  </filter>
</svg>Code language: HTML, XML (xml)

We’ve saved this result as opaque for when we need to use it later.

Screenshot showing what we get after the first  primitive: just the four lines of white text on black packground.
the opaque result

Next, from the initial filter input, we extract the background RGB area by mapping the second (.25) alpha point to 1, while mapping all others to 0. Note that we don’t want the input of the second primitive to be the result of the first one, but the filter input, so we explicitly specify in as SourceGraphic.

<svg width="0" height="0" aria-hidden="true">
  <filter id="go" color-interpolation-filters="sRGB">
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
  </filter>
</svg>
Code language: HTML, XML (xml)

In theory, this second feComponentTransfer extracts second just the background RGB area (pseudo outer shadow area, using the second alpha point, .25). In practice, can you see what else it has picked up?

Screenshot showing what we get after the second  primitive: just the outermost frame, the one holding the background RGB, but now with its alpha set to , fully opaque.
the outer frame using the background RGB

If you cannot pick it up (it’s not easy), let’s remove the image backdrop and circle the problem areas:

Screenshot showing the same outermost frame with the background RGB, now on a white background that allows us to see that in the middle of the rectangle this frame is placed around, there are a few stray black pixels.
highlighting the problem areas

Those black pixels it picks up are again due to anti-aliasing. At the rounded corners of the span background lines, we have semitransparent pixels in order for these corners to look smooth, not jagged. But then our second feComponentTransfer maps the pixels in the [0, .25] interval to [0, 1] and the pixels in the [.25, .5] interval to [1, 0]. And this doesn’t catch just the pixels of the pseudo’s outer shadow using the .25 alpha point, but also the pixels in the [0, .5] interval at those rounded corners of those span background lines, which get a non-zero alpha too.

Now in our particular case where we have a black span background, we can safely just ignore those pixels when moving on to the next step. But if we were to have a red background there, things would be very different and those pixels could cause a lot of trouble.

That’s because at the next step we expand the background RGB frame we got to cover the entire filter area and we do that with a feMorphology primitive using the dilate operation. What this does is the following: for every channel of every pixel, it takes the maximum of all the values of that channel for the pixels lying within the specified radius (from the current pixel) along both the x and the y axes in both the negative and positive direction.

Below, you can see how this works for a channel whose values are either maxed out (1) or zeroed (0). For every pixel of the input (green outline around the current one), the corresponding output value for the same channel is the maximum of all the values for that channel in the vicinity of the current pixel (within the red square).

how dilation works in the general case

For our purpose, we first care about the alpha channel, since this turns opaque all transparent pixels that are within the specified radius from any opaque one along both axes in both directions, effectively dilating our frame to fill the area inside it.

But the maximum computation happens for the RGB channels too. Black has zero for all RGB channels, so those stray pixels don’t affect the result of the maximum computation since every single one of the RGB channels of the frame is above zero, which makes them be the result of the maximum for every single one of the RGB channels.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />
  </filter>
</svg>
Code language: HTML, XML (xml)

Note that the filter now has primitiveUnits set to objectBoundingBox so values for attributes such as the radius attribute of feMorphology are not pixel values anymore, but relative to the filter input box size. This is because the size of our filter area is given by its input, whose exact pixel size is determined by the text content which we have no way of knowing. So we switch to relative units.

dilating the frame to fill the filter area (black span background)

There are two things to keep in mind here.

One, I’m not exactly happy to have to use such a relatively large dilation value, as it can negatively impact performance (at least from the tests on my laptop, the performance hit is obvious in both Firefox and Epiphany for the final demo). But unfortunately, my initial idea of extracting small squares in the top left corner and then tiling them ran into at least one different bug in every browser on at least one OS, so I guess this dilation was the only option left.

Two, if we had a red (rgb(100% 0% 0%)) instead of a black (rgb(0% 0% 0%)) background, then the maxed up red channel would cause trouble since 100% is a bigger value than the 9.8% of the frame (desired RGB being rgb(9.8% 9.8% 43.9%)), so then we’d end up with those pesky corner pixels bloating up and turning the intersection with the dilated frame purple, a mix (rgb(max(100%, 9.8%) max(0%, 9.8%) max(0%, 43.9%))) between the red channel of the initial red span background and the green and blue channels of the frame (which has the desired RGB value for the background and whose red channel we’d lose this way).

dilating the frame to fill the filter area (red span background)

In such a case where a red input area would “contaminate” our desired background RGB, we’d first need to apply a small erosion to get rid of those pesky corner pixels before we apply the dilation. Erosion works in a similar manner to dilation, except we take the minimum channel value of all pixels within the set radius along both axes in both directions.

how erosion works in the general case

In our case, we care about the alpha channel erosions, all the transparent pixels around zeroing the alpha of those few ones we didn’t really mean to pick up.

<feMorphology radius='.01'/>Code language: HTML, XML (xml)

Note that erode is the default operator, so we don’t need to explicitly set it.

Back to our case, after dilating the frame to fill the entire filter area with the desired background RGB and saving this result as back-rgb, we extract (again, out of the initial filter input) the desired alpha as the green channel value of the pseudo border with a .5 alpha. This means another feComponentTransfer, this time one mapping all alpha points to 0, except for the third one (.5), which gets mapped to 1 (though in this one case the exact alpha it gets mapped to doesn’t really matter as long as its non-zero).

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
  </filter>
</svg>
Code language: HTML, XML (xml)

This gives us a green frame (red and blue channels zeroed, green channel set to the value of the desired alpha for the background of the span lines):

Screenshot showing what we get after the third  primitive: just the middle frame, the one holding the background alpha on the green channel, but now with its alpha set to , fully opaque.
the middle frame using the desired alpha on the green channel

Now you can probably guess what follows: we dilate this green frame to cover the entire filter area. Again, we have those stray black pixels, but since they’re black, their channel values just get discarded when we perform the dilation, so we don’t need that erosion step in between.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
  </filter>
</svg>
Code language: HTML, XML (xml)

We don’t save the result of this primitive this time, but we’ll get to that in a moment. This is what we have now – not too exciting yet, though things are about to change.

Screenshot showing the middle frame from before (holding the background alpha on the green channel) dilated in all directions to the point it has filled the entire filter area.
middle frame dilated to fill entire filter area

Next, we use feColorMatrix to give this layer covering the entire filter area an alpha equal to that of its green channel. This is why we don’t save the result of the second feMorphology – because we only feed it into the input of the very next primitive, feColorMatrix and then we don’t need it anywhere after that. We don’t care about the RGB values of the result, only about the alpha, so we just zero them all.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
  </filter>
</svg>
Code language: HTML, XML (xml)

Basically, what this feColorMatrix does is set the output alpha channel to be equal to the input green channel (well, to 1 multiplied with the input green channel), regardless of the values of the other input channels (red, blue, alpha). This way, we recover the alpha channel from the green one.

Screenshot showing the fill from the previous step, now with the green channel value transferred onto the alpha channel, giving us a semi-transparent fill, of the alpha we want for the  background.
alpha value finally on the alpha channel

Next step is to intersect the previously saved back-rgb result with this one, so we keep the RGB channels of that layer and the alpha channel of this one.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
  </filter>
</svg>
Code language: HTML, XML (xml)

What happens here is the alphas of the two input layers (1 for back-rgb and the desired span background alpha for the other) are multiplied to give us the output alpha. At the same time, we only keep the RGB values of the top one (back-rgb) for the output.

Screenshot showing us the result of compositing the full filter area fill background RGB and background alpha layers. This has the desired background RGB and the desired background alpha.
the desired semi-transparent background, filling the filter area

We now have the entire filter area covered by a layer with the desired RGBA for the span background lines, so the next step is to restrict it to the area of those span lines, opaque. That is, only keep it at the intersection with that area and save the result as back.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />
  </filter>
</svg>
Code language: HTML, XML (xml)

It finally looks like we’re getting somewhere!

Screenshot showing us the result of the latest compositing step: the background RGBA layer at the intersection with the initial opaque shape of the  background area.
the semi-transparent dark blue background of the span

Next, we can move on to the text!

We start by extracting the text RGB area by mapping the fourth (.75) alpha point to 1, while mapping all others to 0. Again, we explicitly specify in as SourceGraphic.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
  </filter>
</svg>
Code language: HTML, XML (xml)

This gives us yet another frame, this time one in the gold we want for the text.

What we get after the fourth  primitive: just the innermost frame, the one holding the text RGB, but now with its alpha set to , fully opaque.
the inner frame using the text RGB

Just like we did for the other frames, we dilate this one too in order to make it fill the entire filter area and save this result as text-rgb.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="text-rgb" />
  </filter>
</svg>
Code language: HTML, XML (xml)
Screenshot showing the inner frame from before (holding the text RGB value) dilated in all directions to the point it has filled the entire filter area.
inner frame dilated to fill entire filter area

Then we extract the text shape from the opaque layer, just like we did before, using the green channel like an alpha mask.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="text-rgb" />

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
  </filter>
</svg>
Code language: HTML, XML (xml)

My expectation was that this would give us just the text shape like below, which is what happens in Chrome.

Screenshot showing the Chrome result when extracting the green channel as an alpha mask from the initial opaque portion of the input, the one having white text (all three RGB channels maxed out) on black background (all three RGB channels zeroed). This is just the actal text area in this case, the green channel of the frames appears to have been thrown out when thir alphas got zeroed at the first step which gave us just the fully opaque area.
the text shape extracted using the green channel (Chrome)

However, Firefox does something interesting here and thinking it through, I’m not entirely sure it’s wrong.

Screenshot showing the Chrome result when extracting the green channel as an alpha mask from the initial opaque portion of the input, the one having white text (all three RGB channels maxed out) on black background (all three RGB channels zeroed). This is not just the white text in this case, but also the frames as the filter input had non-zero green channel values there and unlike Chrome, Firefox doesn't seem to have discarded their RGB values when zeroing the frame alphas at the first step extracting just the fully opaque portion.
Firefox extracting more than just the text shapes

What seems to happen is that Chrome forgets all about the RGB values of the semi-transparent areas of the pseudo and just zeroes them when zeroing their alphas in the first feComponentTransfer primitive to extract the opaque part (the span with white text on solid black background). Then when using the green channel as an alpha mask on the opaque part, all that’s not transparent is the white text, where the green channel is maxed out.

However, Firefox doesn’t seem to throw away the RGB values of those semi-transparent frames created by the border and box-shadow on the pseudo, even if it also zeroes their alphas via the first primitive as well. So even though the opaque result looks the same in both browsers, it’s not really the same. Then when we get to this latest feColorMatrix step, Firefox finds green in those now fully transparent frames because even though their alpha got zeroed to get the opaque result, their RGB values got preserved.

Whichever browser is right, there’s a very simple way to get the result we want cross-browser: intersect what we have now with the opaque result. It doesn’t even matter the RGB values of which layer we choose to preserve as a result of this intersection because we won’t be using them anyway.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="text-rgb" />

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="opaque" operator="in" />
  </filter>
</svg>
Code language: HTML, XML (xml)

The next step is to keep the text-rgb layer only at the intersection with the text we just got.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="text-rgb" />

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="opaque" operator="in" />
    <feComposite in="text-rgb" operator="in" />
  </filter>
</svg>
Code language: HTML, XML (xml)
Screenshot showing the golden fll layer kept just at the intersection with the text shape.
the golden text of the span

Finally, we place this on top of the back layer with a feBlend, just like we did before.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="text-rgb" />

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="opaque" operator="in" />
    <feComposite in="text-rgb" operator="in" />
    <feBlend in2="back" />
  </filter>
</svg>
Code language: HTML, XML (xml)

This is our final result!

This allows us to have full control from the CSS over the text and background RGB, as well as over the background alpha, without needing to hardcode any of them in the SVG filter, which means we don’t need a different SVG filter if we want to set a different value for any of them on one o the elements the filter is applied to.

Now you may be thinking… well, this looks ugly with those semi-transparent frames before the filter is applied, so what if the filter fails? Well, the fix is really simple. clip-path gets applied after filter, so we can clip out those frames. They still get used for the filter if the filter is applied, but if it fails, we are still left with the very reasonable choice of white text on black background.

The following demo has different text and background combinations for each paragraph. All paragraphs use the exact same filter (the one above), they just have different values for --text-c--back-c and --a.

Wanna learn SVG & Animation deeply?

Frontend Masters logo

We have an incredible course on all things CSS and SVG animation from Sarah Drasner. Sarah comprehensively covers the possibilty of animation, the tools, and does it all in a very practical way.

7-Day Free Trial

2 responses to “A Deep Dive into the Inline Background Overlap Problem”

  1. Really impressive, although the very last example runs terribly slow on my laptop (i7-13620H) as I disabled Chrome GPU rasterization (since it leads to other issues such as bad SVG antialiasing when Skia Graphite is enabled).

  2. _°00°_ says:

    Somehow this last filter with all the morph in it completely breaks my Firefox. I have to force exit it every time I try to run the codepen…

    Also there seems to be a bug where an hole appears when resizing the page in Chrome so that the font-size gets beyond 26px.

Leave a Reply

Your email address will not be published. Required fields are marked *

Did you know?

Frontend Masters Donates to open source projects. $363,806 contributed to date.