A Deep Dive into the Inline Background Overlap Problem
A Deep Dive into the Inline Background Overlap Problem 관련
Atweet by Lucas Bonomi (LukyVJ
)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.

the problem at hand: the overlapping parts appear darker because of the layered opacity
Temani Afif had already suggested (ChallengesCss
) using an SVGfilter
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 ap
and aspan
. Thespan
gets padding
, border-radius
,and a semi-transparentbackground
.
p > span {
padding: .25em;
border-radius: 5px;
background: rgb(0 0 0/ var(--a, .7));
color: #fff;
box-decoration-break: clone
}
We’re also settingbox-decoration-break: clone
so that each wrapped line gets its ownpadding
and corner rounding (this is a very neat CSS feature that’sworth looking intoif you’re not familiar with it).
The result of the above code looks as follows:

This is pretty much the same as the screenshot Lucas posted, so let’s see how we can fix it with an SVGfilter
!
The first step is to make thebackground
of thespan
opaque by setting--a
to1
. This gets rid of the overlap increasing alpha problem because there is no more transparency. To restore that transparency, we use an SVGfilter
. 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)
}
The SVGfilter
needs to live inside ansvg
element. Since thissvg
element only contains ourfilter
and no actual SVG graphics to be displayed on the screen, it is functionally the same as astyle
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>
svg[height='0'][aria-hidden='true'] { position: fixed }
The first primitive,feComponentTransfer
, takes theSourceAlpha
(basically, thefilter
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 thespan
background. This is because the input alpha is1
within thespan
background area and0
outside it. Multiplying the desired alpha with1
leaves it unchanged, while multiplying it with0
… 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>
We’ve also named theresult
of this primitiveback
so we can reference it later in primitives not immediately folowing this particularfeComponentTransfer
one.

Now we have the semi-transparent multi-linespan
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 afeColorMatrix
primitive that uses thegreen 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 to100%
) all RGB channels of the output (last column, first three rows), basicallypainting the outputwhite 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>
Note that by default, the inputs of any primitives other than the very first one in thefilter
get set to the result of the primitive right before, so for thisfeColorMatrix
primitive we need to explicitly set the inputin
toSourceGraphic
.
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 hita bugwhich causesfeColorMatrix
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 of100%
red, 0%
green and0%
blue doesn’t give us100%
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).

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:

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>
I seefeMerge
often used for this, but here we only have two layers, so I findfeBlend
(with the defaultmode
ofnormal
which just places the top layerin
over the bottom layerin2
) a much simpler solution.
Note that we’re not specifyingin
explicitly because, by default, it’s the result of the previous primitive, thefeColorMatrix
. This is also why we didn’t bother with setting theresult
attribute like we did for the first primitive, thefeComponentTransfer
one because the output of thisfeColorMatrix
primitive only gets fed automatically into thein
input of the final primitive and nowhere else after that.
Cool, right?

thebabydino
))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.
A related problem
Patrick H. Laukepointed metoa CodePen demo (patrickhlauke
)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 enoughpadding
to run into it. But let’s say we increase thepadding
of thespan
from.25em
to.5em
(and also remove thefilter
to make the problem more obvious).

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 setmix-blend-mode
onspan
elements (darken
for black text,lighten
for white) suffices, there’s no need for an SVGfilter
.

Bothdarken
andlighten
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 thedarken
blend mode, the result always shows the black text where there is overlap because the0%
-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 thelighten
blend mode, the result always shows the white text where there is overlap because the100%
-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 thespan
elements also get blended with the image backdrop.

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

thebabydino
))Slightly more complex: long wrapping span, opaque background, black/ white text
In this case, themix-blend-mode
solution isn’t enough anymore because the point of it was to blend thespan
background with the text of the parent paragraph that gets covered. But now it’s thespan
‘s own text that gets covered by thebackground
of its next line.

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

thebabydino
))If you’ve checked the above demo in Firefox, you may have noticed that it doesn’t work. This is due tobug 1951653.
In the particular case when theentiretext in the paragraph is wrapped in aspan
, we can avoid the Firefox bug by setting themix-blend-mode
propertyonlyon the innerspan
(span span
).
However, in the case above, where we also have paragraph text outside the outerspan
too, this unfortunately still leaves us with the problem of that text before the longspan
getting covered by the background of the nextspan
line.

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 SVGfilter
one.
Going back to our original demo, we need to apply the solution from the previous case: wrap thespan
in another, set thepadding
andbackground
only on the outer one (p > span
), blendonlythe innerspan
element with the outer one to ensure our solution works cross-browser (since we have white text, we use thelighten
mode) and prevent blending with anything outside the containing paragraphp
by settingisolation: 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 }
}

thebabydino
))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 sayrgb(25 25 112)
(which can also be written asrgb(9.8% 9.8% 43.9%)
), as well as gold-orange text, let’s sayrgb(255 165 0)
(which can also be written asrgb(100% 64.7% 0%)
), then we usefeColorMatrix
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>
Other than theid
, we’ve now also setanother attributeon thefilter
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 SVGfilter
that messes with the RGB channels. Otherwise, the result won’t be consistent between browsers (the default islinearRGB
in theory, but only thesRGB
value seems to work in Safari) and it may not match expectations (thesRGB
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.

thebabydino
))The problem with this solution is that it involves hardcoding the RGBA values for both thespan
background and text in the SVGfilter
, 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 thesvg
:
body {
--a: .5;
--back-c: rgb(25 25 112/ var(--a));
--text-c: rgb(255 165 0)
}
Then we modify thefilter
a bit. We useSourceAlpha
to give us the background area, though we still extract the text area via afeColorMatrix
primitive and save it astext
, but this time we don’t care about the RGB values, we won’t use them anyway. We also flood the entirefilter
area with--back-c
and--text-c
(usingfeFlood
), but then, out of the entire area, we only keep what’s at the intersection (operator='in'
offeComposite
) with theSourceAlpha
andtext
areas respectively. Finally, we stack these intersections (viafeBlend
), 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>
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 thefeFlood
primitive, not those on the element thefilter
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)
}
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)
}
But changing these variables on the second paragraphdoesn’t do anything (thebabydino
)for the result of the SVGfilter
applied to it because the values for--back-c
and--text-c
that get used by thefilter
arealwaysthose set upstream from it on thebody
.
Unfortunately, this is just how things are for SVG filters, even though CSS ones don’t have this limitation, like the comparison below shows.

flood-color
(live demo (thebabydino
))Set RGB values in the CSS, fix alpha in the SVG filter
Amelia Bellamy-RoydssuggestedafeComponentTransfer
approach (AmeliaBR
)that allows setting the palette from the CSS and then using the SVGfilter
only to take care of the increase in alpha where there is overlap.
What Amelia’sfilter
does is usefeComponentTransfer
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 alphaa
. This should also catch and map the background overlap alpha (which is– for more details, see thisAdventures in CSS Semi-Transparency Landarticle) toa
.
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.

.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 excluding2*a - a*a
and1 - a
from the minimum computation sincea
is subunitary, soa
is always bigger thana*a
, which results ina
being always smaller than2*a - a*a = a*(2 - a)
, which also results in1 + a*a - 2*a
being smaller than1 - a
.
Then we get how many such base intervalsu
we could fit between0
and1
, round up this number (n
) and then generate the list of alpha values (fortableValues
) which remains0
and1
at the ends, but is set toa
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(' '))
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'/>
We also have another bigger problem: due to font anti-aliasing, thefeComponentTransfer
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 ajagged, 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:

Those semi-transparent font edge pixels placed on top of the semi-transparent background also give us semi-transparent pixels. At the same time, ourfilter
maps the alpha of more and more of the semi-transparent pixels of the input to the desired background alphaa
as thisa
nears the ends of the[0, 1]
interval. Asa
nears0
, then almost all semi-transparent edge pixels get this very lowa
alpha, making them much more transparent than they should be and causing an eroded look for our text.
I guess a simple fix for that would be to only map to the desired alphaa
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 alphaa
is equal to it or the nearest smaller than it, while the last one is equal to the overlap alpha2*a - a*a
or the nearest bigger than it.
For example, if the desired alphaa
is.2
, then the overlap alpha is. The base intervalu
is.2
,n
is, so we generatealpha points:
0 .2 .4 .6 .8 1
If before we mapped all those between0
and1
to the desired alpha.2
, now we only map to the desired alphaa
, 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);
Probably ensuring the values outside the interval mapped toa
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.
But you may have noticed there’s still a problem and this is not an SVGfilter
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 SVGfilter
– can you see it?
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 viafeComponentTransfer
?

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 textcolor
not being preserved. This was precisely why we switched from a blending-only solution to an SVGfilter
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 havea 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 fadingfor 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 beingrgb(100% 74.51% 4.31%)
and the background beingrgb(22.75% 21.18% 100%)
(we leave out the transparency for now and assume the alpha is1
). Blending these using thelighten
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 and100%
(max(4.31%, 100%)
) for the blue one. That means our text isrgb(100% 74.51% 100%)
, a light pink, which is different from thecolor
value ofrgb(100% 74.51% 4.31%)
(golden) we’ve set.
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 beingrgb(0% 100% 0%)
and the background beingrgb(0% 0% 100%)
(again, we leave out the transparency for now and assume the alpha is1
). Blending these using thelighten
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 and100%
(max(0%, 100%)
) for the blue one. That means our text isrgb(0% 100% 100%)
, so aqua, which is different from thecolor
value ofrgb(0% 100% 0%)
(lime) we’ve set.
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 the1
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 except1
to0
.
We pass the text and background RGB values using the.75
and.25
alpha points – this allows us to extract them in the SVGfilter
by mapping their corresponding alpha points to1
, while all other alpha points are0
.
Finally, we pass the alpha value to the SVG via the green channel, using the.5
alpha point. By mapping the.5
alpha point to1
, while all other alpha points get mapped to0
, we can extract in the SVGfilter
the desired background alpha value via the green channel value.
This means we have five alpha points (0
,.25
,.5
,.75
and1
), so we’re going to need to use five values for thetableValues
attribute offeFuncA
, all of them zeroed, except the one corresponding to the point we’re interested in and which we map to1
.
In order to do this, we first add an absolutely positioned, non-clickable pseudo on thep
element. This pseudo has aborder
and two shadows (an outer one and aninset
one) and is offset outwards (using a negativeinset
) to compensate for both theinset
shadow and theborder
, so that there is no visible part of this pseudo intersecting thespan
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: "";
}
}
The first shadow is aninset
one using the desired text RGB value and a.75
alpha, which allows us to pass the RGB value to the SVGfilter
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 SVGfilter
via the.25
alpha point.
Theborder-color
uses the desiredspan
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 SVGfilter
the value of the desiredspan
background alpha as the green channel value using the.5
alpha point.
The negativeinset
(-2em
) is set to compensate for both theinset
shadow (with a1em
spread) and for theborder
(with a1em
width) because it’s very important that none of the visible parts of the pseudo (theborder
and thebox-shadow
using the.25
,.5
and.75
alpha points) intersect the shape of thespan
background (using the1
alpha point).
Thepointer-events: none
property is there in order to avoid any interference with thespan
text selection. We could have also usedz-index: -1
, since there is no intersection between the visible parts of the pseudo and thespan
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!

Moving on to thefilter
, we start in a similar manner as before, by getting theopaque
part. To do so, we preserve just just the fifth alpha point (1
), while mapping all others to0
. Everything that intially has an alpha of0
(transparent part inside the frames around thespan
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>
We’ve saved this result asopaque
for when we need to use it later.

Next, from the initialfilter
input, we extract the background RGB area by mapping the second (.25
) alpha point to1
, while mapping all others to0
. Note that we don’t want the input of the second primitive to be theresult
of the first one, but thefilter
input, so we explicitly specify in
asSourceGraphic
.
<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>
In theory, this secondfeComponentTransfer
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?

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

Those black pixels it picks up are again due to anti-aliasing. At the rounded corners of thespan
background lines, we have semitransparent pixels in order for these corners to look smooth, not jagged. But then our secondfeComponentTransfer
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 thosespan
background lines, which get a non-zero alpha too.
Now in our particular case where we have a blackspan
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 entirefilter
area and we do that with afeMorphology
primitive using thedilate
operation. What this does is the following: for every channel of every pixel, it takes themaximumof all the values of that channel for the pixels lying within the specifiedradius
(from the current pixel) along both thexand theyaxes 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).
For our purpose, we first care about the alpha channel, since this turns opaque all transparent pixels that are within the specifiedradius
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>
Note that thefilter
now hasprimitiveUnits
set toobjectBoundingBox
so values for attributes such as theradius
attribute offeMorphology
are not pixel values anymore, but relative to thefilter
input box size. This is because the size of ourfilter
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.
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 since100%
is a bigger value than the9.8%
of the frame (desired RGB beingrgb(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 redspan
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).
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 pixelsbeforewe apply the dilation. Erosion works in a similar manner to dilation, except we take theminimumchannel value of all pixels within the setradius
along both axes in both directions.
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'/>
Note thaterode
is the defaultoperator
, so we don’t need to explicitly set it.
Back to our case, after dilating the frame to fill the entirefilter
area with the desired background RGB and saving this result asback-rgb
, we extract (again, out of the initialfilter
input) the desired alpha as the green channel value of the pseudo border with a.5
alpha. This means anotherfeComponentTransfer
, this time one mapping all alpha points to0
, except for the third one (.5
), which gets mapped to1
(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>
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 thespan
lines):

Now you can probably guess what follows: wedilate
this green frame to cover the entirefilter
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>
We don’t save theresult
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.

Next, we usefeColorMatrix
to give this layer covering the entirefilter
area an alpha equal to that of its green channel. This is why we don’t save the result of the secondfeMorphology
– 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 theresult
, 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>
Basically, what thisfeColorMatrix
does is set the output alpha channel to be equal to the input green channel (well, to1
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.

Next step is to intersect the previously savedback-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>
What happens here is the alphas of the two input layers (1
forback-rgb
and the desiredspan
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.

We now have the entire filter area covered by a layer with the desired RGBA for thespan
background lines, so the next step is to restrict it to the area of thosespan
lines,opaque
. That is, only keep it at the intersection with that area and save theresult
asback
.
<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>
It finally looks like we’re getting somewhere!

span
Next, we can move on to the text!
We start by extracting the text RGB area by mapping the fourth (.75
) alpha point to1
, while mapping all others to0
. 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>
This gives us yet another frame, this time one in the gold we want for the text.

Just like we did for the other frames, wedilate
this one too in order to make it fill the entirefilter
area and save this result astext-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>

Then we extract the text shape from theopaque
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>
My expectation was that this would give us just the text shape like below, which is what happens in Chrome.

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

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 theopaque
part (thespan
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 theborder
andbox-shadow
on the pseudo, even if it also zeroes their alphas via the first primitive as well. So even though theopaque
result looks the same in both browsers, it’s not really the same. Then when we get to this latestfeColorMatrix
step, Firefox finds green in those now fully transparent frames because even though their alpha got zeroed to get theopaque
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 theopaque
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>
The next step is to keep thetext-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>

span
Finally, we place this on top of theback
layer with afeBlend
, 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>
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 SVGfilter
, which means we don’t need a different SVGfilter
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 isapplied
, 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 thefilter
if thefilter
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 samefilter
(the one above), they just have different values for--text-c
,--back-c
and--a
.