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 SVGfiltersolution, 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 apand aspan. Thespangets 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: cloneso that each wrapped line gets its ownpaddingand 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 thebackgroundof thespanopaque by setting--ato1. 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 SVGfilterneeds to live inside ansvgelement. Since thissvgelement only contains ourfilterand no actual SVG graphics to be displayed on the screen, it is functionally the same as astyleelement, 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, thefilterinput, 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 thespanbackground. This is because the input alpha is1within thespanbackground area and0outside it. Multiplying the desired alpha with1leaves 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 theresultof this primitivebackso we can reference it later in primitives not immediately folowing this particularfeComponentTransferone.

Now we have the semi-transparent multi-linespanbackground 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 afeColorMatrixprimitive 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 thefilterget set to the result of the primitive right before, so for thisfeColorMatrixprimitive we need to explicitly set the inputintoSourceGraphic.
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 causesfeColorMatrixto 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 (in2specifies 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 seefeMergeoften used for this, but here we only have two layers, so I findfeBlend(with the defaultmodeofnormalwhich just places the top layerinover the bottom layerin2) a much simpler solution.
Note that weâre not specifyinginexplicitly because, by default, itâs the result of the previous primitive, thefeColorMatrix. This is also why we didnât bother with setting theresultattribute like we did for the first primitive, thefeComponentTransferone because the output of thisfeColorMatrix primitive only gets fed automatically into theininput 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 enoughpaddingto run into it. But letâs say we increase thepaddingof thespanfrom.25emto.5em(and also remove thefilterto 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-modeonspanelements (darkenfor black text,lightenfor white) suffices, thereâs no need for an SVGfilter.

Bothdarkenandlightenvalue 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 thedarkenblend 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 thelightenblend 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 thespanelements also get blended with the image backdrop.

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

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

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

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-modepropertyonlyon the innerspan(span span).
However, in the case above, where we also have paragraph text outside the outerspantoo, this unfortunately still leaves us with the problem of that text before the longspangetting covered by the background of the nextspanline.

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 SVGfilterone.
Going back to our original demo, we need to apply the solution from the previous case: wrap thespanin another, set thepaddingandbackgroundonly on the outer one (p > span), blendonlythe innerspanelement with the outer one to ensure our solution works cross-browser (since we have white text, we use thelightenmode) and prevent blending with anything outside the containing paragraphpby settingisolation: isolateon 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 usefeColorMatrixas 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 thefilterelement. 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 SVGfilterthat messes with the RGB channels. Otherwise, the result wonât be consistent between browsers (the default islinearRGBin theory, but only thesRGBvalue seems to work in Safari) and it may not match expectations (thesRGBvalue 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 thespanbackground 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 thefiltera bit. We useSourceAlphato give us the background area, though we still extract the text area via afeColorMatrixprimitive 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 entirefilterarea with--back-cand--text-c(usingfeFlood), but then, out of the entire area, we only keep whatâs at the intersection (operator='in'offeComposite) with theSourceAlphaandtextareas 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-cand--text-care those of thefeFloodprimitive, not those on the element thefilterapplies 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-cand--text-con 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 SVGfilterapplied to it because the values for--back-cand--text-cthat get used by thefilterarealwaysthose 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-RoydssuggestedafeComponentTransferapproach (AmeliaBR)that allows setting the palette from the CSS and then using the SVGfilteronly to take care of the increase in alpha where there is overlap.
What Ameliaâsfilterdoes is usefeComponentTransferto 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.

.75My 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*aand1 - afrom the minimum computation sinceais subunitary, soais always bigger thana*a, which results inabeing always smaller than2*a - a*a = a*(2 - a), which also results in1 + a*a - 2*abeing smaller than1 - a.
Then we get how many such base intervalsuwe could fit between0and1, round up this number (n) and then generate the list of alpha values (fortableValues) which remains0and1at the ends, but is set toaeverywhere 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, ourfiltermaps the alpha of more and more of the semi-transparent pixels of the input to the desired background alphaaas thisanears the ends of the[0, 1]interval. Asanears0, then almost all semi-transparent edge pixels get this very lowaalpha, 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 alphaathe 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 alphaais equal to it or the nearest smaller than it, while the last one is equal to the overlap alpha2*a - a*aor the nearest bigger than it.
For example, if the desired alphaais.2, then the overlap alpha is. The base intervaluis.2,nis, so we generatealpha points:
0 .2 .4 .6 .8 1
If before we mapped all those between0and1to the desired alpha.2, now we only map to the desired alphaa, those loosely matching the[.2, .36]interval - that is,.2and.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 toaare 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 SVGfilterone, 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 textcolornot being preserved. This was precisely why we switched from a blending-only solution to an SVGfilterone 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 thelightenblend 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 thecolorvalue 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 thelightenblend 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 thecolorvalue 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 the1alpha 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 except1to0.
We pass the text and background RGB values using the.75and.25alpha points - this allows us to extract them in the SVGfilterby 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.5alpha point. By mapping the.5alpha point to1, while all other alpha points get mapped to0, we can extract in the SVGfilterthe desired background alpha value via the green channel value.
This means we have five alpha points (0,.25,.5,.75and1), so weâre going to need to use five values for thetableValuesattribute 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 thepelement. This pseudo has aborderand two shadows (an outer one and aninsetone) and is offset outwards (using a negativeinset) to compensate for both theinsetshadow and theborder, so that there is no visible part of this pseudo intersecting thespanbackground 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 aninsetone using the desired text RGB value and a.75alpha, which allows us to pass the RGB value to the SVGfiltervia the.75alpha point. The second shadow is an outer one using the desired background RGB value and a.25alpha, which allows us to pass the RGB value to the SVGfiltervia the.25alpha point.
Theborder-coloruses the desiredspanbackground 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.5alpha. This allows us to pass to the SVGfilterthe value of the desiredspanbackground alpha as the green channel value using the.5alpha point.
The negativeinset(-2em) is set to compensate for both theinsetshadow (with a1emspread) and for theborder(with a1emwidth) because itâs very important that none of the visible parts of the pseudo (theborderand thebox-shadowusing the.25,.5and.75alpha points) intersect the shape of thespanbackground (using the1alpha point).
Thepointer-events: noneproperty is there in order to avoid any interference with thespantext selection. We could have also usedz-index: -1, since there is no intersection between the visible parts of the pseudo and thespanbackground 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 theopaquepart. 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 thespanshape),.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 asopaquefor when we need to use it later.

Next, from the initialfilterinput, 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 theresultof the first one, but thefilterinput, so we explicitly specify inasSourceGraphic.
<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 secondfeComponentTransferextracts 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 thespanbackground 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.25alpha point, but also the pixels in the[0, .5]interval at those rounded corners of thosespanbackground lines, which get a non-zero alpha too.
Now in our particular case where we have a blackspanbackground, 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 entirefilterarea and we do that with afeMorphologyprimitive using thedilateoperation. 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 specifiedradiusfrom 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 thefilternow hasprimitiveUnitsset toobjectBoundingBoxso values for attributes such as theradiusattribute offeMorphologyare not pixel values anymore, but relative to thefilterinput box size. This is because the size of ourfilterarea 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 redspanbackground 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 setradiusalong 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 thaterodeis the defaultoperator, so we donât need to explicitly set it.
Back to our case, after dilating the frame to fill the entirefilterarea with the desired background RGB and saving this result asback-rgb, we extract (again, out of the initialfilterinput) the desired alpha as the green channel value of the pseudo border with a.5alpha. 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 thespanlines):

Now you can probably guess what follows: wedilatethis green frame to cover the entirefilterarea. 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 theresultof 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 usefeColorMatrixto give this layer covering the entirefilterarea 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,feColorMatrixand 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 thisfeColorMatrixdoes is set the output alpha channel to be equal to the input green channel (well, to1multiplied 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-rgbresult 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 (1forback-rgband the desiredspanbackground 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 thespanbackground lines, so the next step is to restrict it to the area of thosespanlines,opaque. That is, only keep it at the intersection with that area and save theresultasback.
<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!

spanNext, 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, wedilatethis one too in order to make it fill the entirefilterarea 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 theopaquelayer, 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 theopaquepart (thespanwith 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 theborderandbox-shadowon the pseudo, even if it also zeroes their alphas via the first primitive as well. So even though theopaqueresult looks the same in both browsers, itâs not really the same. Then when we get to this latestfeColorMatrixstep, Firefox finds green in those now fully transparent frames because even though their alpha got zeroed to get theopaqueresult, 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 theopaqueresult. 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-rgblayer 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>

spanFinally, we place this on top of thebacklayer 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 SVGfilterif 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-pathgets applied after filter, so we can clip out those frames. They still get used for thefilterif thefilteris 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-cand--a.