The state of things in 2023 - in short, we have the following options:
- Pure CSS with
mix-blend-mode
: limited to fully white
/ black
backgrounds on the p
when using the screen
/ multiply
blend modes, or at best all 3 channels of the background
on the p
having higher/ lower values than the corresponding channels of any pixels of the body
background image when using lighten
/ darken
- Pure CSS with
background-clip: text
: while this has evolved from a non-standard feature into a standard one and support has massively improved, it's still limited by alignment to the body
background + involves nesting due to buggy behaviour in Firefox
- Using an SVG
filter
to make the text pixels transparent: has excellent support all the way back to IE9 and not many limitations
In detail:
Pure CSS: mix-blend-mode
This is very straightforward, the relevant code is just:
p {
background: #fff;
color: #000;
mix-blend-mode: screen
}
Anything else is just unrelated layout/ prettifying fluff, not affecting getting the effect we want.
Note the snippet below only works properly if you have a fully white
background (which may or may not have semi-transparency).
html, body { display: grid }
html { height: 100% }
body {
background: url(https://images.unsplash.com/photo-1506383631675-0b110111327b?w=1400) 50%/ cover
}
p {
place-self: center;
padding: 0 0.125em;
width: min-content;
background: #fff;
font: 900 clamp(1.25em, 25vmin, 25em)/ 1.125 sans-serif;
text-align: center;
mix-blend-mode: screen
}
<p contenteditable>Hello, world!</p>
What's happening here?
Blending is an operation applied when we have two layers one on top of the other, on a per pixel and, in some cases (those we care about here), per channel basis.
What any blend mode we might consider for such an effect does is th following: take every pixel of the element it's applied on (the p
in this case) and the corresponding pixel of what's behind (the body
background image in this case) and perform a computation involving the decimal representations of the percentage RGB channel values of the two corresponding pixels.
How the screen
blend mode works
In the case of the screen
blend mode, if C₁ is a generic channel (R,G, or B, doesn't matter, the formula is the same) of a top layer pixel and C₀ is a generic channel value of the corresponding pixel in the bottom layer, the formula is as follows:
1 - (1 - C₁)·(1 - C₀)
Let's say the pixel at the intersection between the ith column of pixels and the jth row of pixels of the p
element is a black
one, corresponding to the actual text (that is rgb(0%, 0%, 0%)
, all three channel values at 0%
). Let's say the corresponding pixel in the body
background image is rgb(41%, 55%, 91%)
- a sort of greyish blue.
Then the resulting RGB channels of that pixel after the screen
blend mode operation are:
1 - (1 - 0)·(1 - .41) = 1 - 1·.59 = 1 - .59 = .41
1 - (1 - 0)·(1 - .55) = 1 - 1·.45 = 1 - .45 = .55
1 - (1 - 0)·(1 - .91) = 1 - 1·.09 = 1 - .09 = .91
That is, the exact same channel values as for the pixel in the body
background image!
In general, when a pixel in one of the two layers is black
and we use the screen
blend mode, then the result of blending is the RGB value of the pixel in the other layer (not the one with the black
pixel).
This is because when we have a black
pixel, all its three channels are at 0%
, 0
in decimal representation, so either the 1 - C₁
or the 1 - C₀
part (depending in which of the two layers the pixels is black
) is 1 - 0 = 1
, thus making the entire product part equal to the other factor, which is the complement of the channel value of the pixel in the other layer. And then complementing the complement (1 - (1 - a) = a
), we get the initial value of the channel in the other layer.
Now let's say the pixel at the intersection between the ith column of pixels and the jth row of pixels of the p
element is a white
one, corresponding to the background
(that is rgb(100%, 100%, 100%)
, all three channel values maxed out at 100%
). Let's say the corresponding pixel in the background image the same greyish blue rgb(41%, 55%, 91%)
.
Then the resulting RGB channels of that pixel after the screen
blend mode operation are:
1 - (1 - 1)·(1 - .41) = 1 - 0·.59 = 1 - 0 = 1
1 - (1 - 1)·(1 - .55) = 1 - 0·.45 = 1 - 0 = 1
1 - (1 - 1)·(1 - .91) = 1 - 0·.09 = 1 - 0 = 1
That is, rgb(100%, 100%, 100%)
or white
!
In general, when a pixel in one of the two layers is white
and we use the screen
blend mode, then the result of blending is also white
.
This is because when we have a white
pixel, all its three channels are maxed out at 100%
, 1
in decimal representation, so either the 1 - C₁
or the 1 - C₀
part (depending in which of the two layers the pixel is white
) is 1 - 1 = 0
, thus making the entire product part 0
and the end result 1
(or 100%
). And all three channel values maxed out at 100%
, that means white
.
Where the screen
approach breaks
This also means that for any background
of our p
that's not fully white
, this approach fails.
Let's say our p
has a peachpuff background, something that's still very light to contrast with a dark background, that is rgb(100%, 86%, 73%)
. Replacing in our formula (and considering the same RGB value for the corresponding background pixel), we have the following for the three channels:
1 - (1 - 1)·(1 - .41) = 1 - 0·.59 = 1 - 0 = 1
1 - (1 - .86)·(1 - .55) = 1 - .14·.45 = 1 - .063 = .937
1 - (1 - .73)·(1 - .91) = 1 - .17·.09 = 1 - .0153 = .9847
So the result is rgb(100%, 93.7%, 98.47%)
, which is different from the peachpuff background
we started with, rgb(100%, 86%, 73%)
. They're both reddish and very light, but they're different and this difference depends on the RGB channels of the body
background underneath.
The screenshot below illustrates this problem we run into if we want any background
that's not fully white
for our p
:
And this peachpuff is a very light, a very close to white
option for the background
of our p
. The darker the background
, the more obvious the problem. Here's crimson
in action:
Yikes!
lighten
- maybe another acceptable option?
Now if the body
background image is very dark (just dark, but around the same channel(s) as the background
we want for our p
might work too), we might get away with changing the blend mode to lighten
.
For example, something like below, where the body
background image is just dark greens, while the we've set background: lightgreen
for our p
:
html, body { display: grid }
html { height: 100% }
body {
background: url(https://images.unsplash.com/photo-1533563906091-fdfdffc3e3c4?w=1400) 50%/ cover
}
p {
place-self: center;
padding: 0 0.125em;
width: min-content;
background: lightgreen;
font: 900 clamp(1.25em, 25vmin, 25em)/ 1.125 sans-serif;
text-align: center;
mix-blend-mode: lighten
}
<p contenteditable>Hello, world!</p>
How the lighten
blend mode works
In the case of the lighten
blend mode, if C₁ is a generic channel (R,G, or B, again, it doesn't matter, the formula is the same) of a top layer pixel and C₀ is a generic channel value of the corresponding pixel in the bottom layer, the formula is as follows:
max(C₁, C₀)
Really, that's it!
Let's say the pixel at the intersection between the ith column of pixels and the jth row of pixels of the p
element is a black
one, corresponding to the actual text (that is rgb(0%, 0%, 0%)
, all three channel values at 0%
). Let's say the corresponding pixel in the body
background image is rgb(4%, 13%, 6%)
- a very dark green.
Then the resulting RGB channels of that pixel after the lighten
blend mode operation are:
max(0, .04) = .04
max(0, .13) = .13
max(0, .06) = .06
Again that's the exact same channel values as for the pixel in the body
background image!
This is because when we have a black
pixel, all its three channels are at 0%
, 0
in decimal representation. And any other channel value, which is always in the [0, 1]
range is at least as big as 0
- any number from 0
to 1
is greater or equal to 0
. So the maximum between 0
and a value in the [0, 1]
interval is always equal to the value in the [0, 1]
interval.
Now let's say the pixel at the intersection between the ith column of pixels and the jth row of pixels of the p
element is a lightgreen
one, corresponding to the background
of the p
(that is rgb(57%, 93%, 57%)
). Let's say the corresponding pixel in the background image is the same very dark green - rgb(4%, 13%, 6%)
.
Then the resulting RGB channels of that pixel after the lighten
blend mode operation are:
max(.57, .04) = .57
max(.93, .13) = .93
max(.57, .06) = .57
That's the same as the lightgreen
we chose as the background
of the p
!
Now the background
we've chosen here for the body
is very dark, which means the channel values of its pixels are very close to zero, so we have a pretty good chance of making this work with a lot of background
options for our p
.
For example, even if we were to chose something like hotpink
for the background
of our p
, its channel values (rgb(100%, 41%, 71%)
) are still bigger than those of the pixels in our very dark background, so the result of blending with it is still hotpink
.
Where the lighten
approach breaks
However, if we were to choose something like a background: red
for our p
, this approach also fails, as the green and blue channel values are 0
for red
(rgb(100%, 0%, 0%)
) - that is, lower than even the very low values we have for our dark green background image on the body
. You can see below how the result of blending wouldn't be rgb(100%, 0%, 0%)
anymore:
max(1, .04) = 1
max(0, .13) = .13
max(0, .06) = .06
And here's a screenshot of what it would look like:
Ugh...
And it would only be worse if the layer underneath, the background-image
on the body
, wasn't as dark. Take the first image we used in our examples - not even the hotpink
background that worked for our p
with the darker body
background image would still work out now.
At the other lightness end
Let's also see a pure CSS way for the case where the background-image
on the body
is a very bright one and the background
of our p
is a very dark one.
Since we want to see through to the background
of body
in the area occupied by the actual text letters, we can choose any initial RGB value we want for them and, in this case, we choose white
.
So in the case when we start with background: black
on our p
, we may use the multiply
blend mode.
html, body { display: grid }
html { height: 100% }
body {
background: url(https://images.unsplash.com/photo-1489069313310-63735a4f3010?w=1400) 50%/ cover
}
p {
place-self: center;
padding: 0 0.125em;
width: min-content;
background: #000;
color: #fff;
font: 900 clamp(1.25em, 25vmin, 25em)/ 1.125 sans-serif;
text-align: center;
mix-blend-mode: multiply
}
<p contenteditable>Hello, world!</p>
How the multiply
blend mode works
In the case of the multiply
blend mode, if C₁ is a generic channel (R,G, or B, doesn't matter) of a top layer pixel and C₀ is a generic channel value of the corresponding pixel in the bottom layer, the formula is:
C₁·C₀
Let's say the pixel at the intersection between the ith column of pixels and the jth row of pixels of the p
element is a white
one, corresponding to the actual text (that is rgb(100%, 100%, 100%)
, all three channel values maxed out at 0%
). Let's say the corresponding pixel in the body
background image is rgb(45%, 77%, 80%)
- a sort of light cyan.
Then the resulting RGB channels of that pixel after the multiply
blend mode operation are:
1·.45 = .45
1·.77 = .77
1·.80 = .80
That is, the exact same channel values as for the pixel in the body
background image!
In general, when a pixel in one of the two layers is white
and we use the multiply
blend mode, then the result of blending is the RGB value of the pixel in the other layer (not the one with the white
pixel).
This is because when we have a white
pixel, all its three channels are maxed out at 100%
, 1
in decimal representation, which makes the entire product equal to the other factor.
Now let's say the pixel at the intersection between the ith column of pixels and the jth row of pixels of the p
element is a black
one, corresponding to the background
(that is rgb(100%, 100%, 100%)
, all three channel values maxed out at 100%
). Let's say the corresponding pixel in the background image the same light cyan rgb(45%, 77%, 80%)
.
Then the resulting RGB channels of that pixel after the multiply
blend mode operation are:
0·.45 = 0
0·.77 = 0
0·.80 = 0
That is, rgb(0%, 0%, 0%)
or black
!
In general, when a pixel in one of the two layers is black
and we use the multiply
blend mode, then the result of blending is also black
.
This is because when we have a black
pixel, all its three channels are at 0%
, 0
in decimal representation, so either the C₁
or the C₀
(depending in which of the two layers the pixel is black
) is 0
, thus making the entire product/ end result 0
(or 0%
). And all three channel values at 0%
, that means black
.
Where the multiply
approach breaks
For any background
of our p
that's not fully black
, this approach fails.
Let's say our p
has an indigo background
(rgb(29%, 0%, 51%)
), something that's still very dark to contrast with a bright body
background image. Replacing in our formula (and considering the same rgb(45%, 77%, 80%)
value for the corresponding background pixel), we have the following for the three channels:
.29·.45 = .1305
0·.77 = 0
.51·.80 = .408
The result is rgb(13.05%, 0%, 40.8%)
, which is different from the indigo background
we started with, rgb(29%, 0%, 51%)
. They're both dark, but they're different and this difference depends on the RGB channels of the body
background underneath.
The screenshot below illustrates this problem we run into if we want any background
that's not fully black
for our p
when using the multiply
blend mode:
The lighter the background
of the p
, the more obvious the problem is.
darken
- maybe another acceptable option?
Now if the body
background image is very bright (just bright, but around the same channel(s) as the background
we want for our p
might work too), we might get away with changing the blend mode to darken
.
For example, something like below, where the body
background image is mostly just light orange-pink, while the we've set background: maroon
for our p
:
html, body { display: grid }
html { height: 100% }
body {
background: url(https://images.unsplash.com/photo-1516464278939-6c47180c46eb?w=1400) 50%/ cover
}
p {
place-self: center;
padding: 0 0.125em;
width: min-content;
background: maroon;
color: #fff;
font: 900 clamp(1.25em, 25vmin, 25em)/ 1.125 sans-serif;
text-align: center;
mix-blend-mode: darken
}
<p contenteditable>Hello, world!</p>
How the darken
blend mode works
In the case of the darken
blend mode, if C₁ is a generic channel (R,G, or B, again, it doesn't matter, the formula is the same) of a top layer pixel and C₀ is a generic channel value of the corresponding pixel in the bottom layer, the formula is as follows:
min(C₁, C₀)
Again, a very simple formula.
Let's say the pixel at the intersection between the ith column of pixels and the jth row of pixels of the p
element is a white
one, corresponding to the actual text (that is rgb(100%, 100%, 100%)
, all three channel values maxed out at 100%
). Let's say the corresponding pixel in the body
background image is rgb(100%, 49%, 44%)
- a kind of light salmon.
Then the resulting RGB channels of that pixel after the darken
blend mode operation are:
min(1, 1) = 1
min(1, .49) = .49
min(1, .44) = .44
Again that's the exact same channel values as for the pixel in the body
background image!
This is because when we have a white
pixel, all its three channels are at maxed out 100%
, 1
in decimal representation. And any other channel value, which is always in the [0, 1]
range is at least as small as 1
- any number from 0
to 1
is smaller or equal to 1
. So the minimum between 1
and a value in the [0, 1]
interval is always equal to the value in the [0, 1]
interval.
Now let's say the pixel at the intersection between the ith column of pixels and the jth row of pixels of the p
element is a maroon
one, corresponding to the background
of the p
(that is rgb(50%, 0%, 0%)
). Let's say the corresponding pixel in the background image is the same light salmon - rgb(100%, 49%, 44%)
.
Then the resulting RGB channels of that pixel after the darken
blend mode operation are:
min(.5, 1) = .5
min(0, .49) = 0
min(0, .44) = 0
That's the same as the maroon
we chose as the background
of the p
!
Where the darken
approach breaks
However, if we were to choose something like a background: indigo
for our p
, this approach also fails, as the blue channel value is 51%
for indigo
(rgb(29%, 0%, 51%)
) - that is, lower than even the very low values we have for our dark green background image on the body
. You can see below how the result of blending wouldn't be rgb(29%, 0%, 51%)
anymore:
min(.29, 1) = .29
min(0, .49) = 0
min(.51, .44) = .44
And here's a screenshot of what it would look like - even worse than when the same background: indigo
on the p
fails with the multiply
blend mode:
Pure CSS: background-clip: text
This has already been mentioned, so I'd normally avoid going into it again, especially since it's not a solution I would use given its scroll complications, but so much has changed in the decade that passed since the previous answers!
background-clip: text
is now standard, supported by all browsers, doesn't require the -webkit-
prefix anymore in any of the modern browsers (or maybe only needs it on mobile, where I haven't tested, as I myself don't have a smartphone and in my environment don't have access to a smartphone that's less than 10 years old and running old versions of everything) and can be used in the shorthand.
html, body { display: grid }
html { height: 100% }
body { background: url('https://images.unsplash.com/photo-1489069313310-63735a4f3010?w=1400') 50%/ cover fixed }
p {
place-self: center;
position: relative;
padding: 0 .125em;
width: min-content;
background: black 50%/ cover text fixed;
background-image: inherit;
color: transparent;
font: 900 clamp(1.25em, 25vmin, 25em)/ 1.125 sans-serif;
text-align: center;
&::before {
position: absolute;
inset: 0;
z-index: -1;
background: indigo;
content: ''
}
}
<p contenteditable>Hello, world</p>
No need for any workarounds to emulate it in other browsers, such as -moz-element()
in Firefox (prefixed version of the standard element()
version, which Chromium browsers haven't implemented and probably won't implement due to security concerns).
The problem with this is it requires to set the same background-image
on both the body
and the p
element and the images need to be aligned. Something that's easily achievable with background-attachment: fixed
, but this means the background
cannot scroll with the page. May be totally fine in some cases, but not in others.
The even more annoying problem with this is that we need to use up a pseudo-element due to a Firefox bug soon old enough to go to school.
Without this bug, this little bit of code would do it:
body { background: url(myimage.jpg) 50%/ cover fixed }
p {
background:
url(myimage.jpg) 50%/ cover text fixed,
linear-gradient(indigo 0 0);
color: transparent
}
Note the background-clip: text
approach means we run into problems if we need to scroll.
Using an SVG filter
: create real text transparency
The idea is the following: if we don't want our background to be black
, then we make our text black
(that is, having all three RGB channels zeroed rgb(0, 0, 0)
) and we apply a filter
that gives us a result whose alpha channel is computed out of the RGBA channels of its input as follows:
v·R + v·G + v·B + 0·A + 0 = v·(R + G + B) + 0 = v·(R + G + B)
This means the alpha of the result is 0
wherever the RGB channels of the input are all 0
, so wherever the pixels of the input are rgb(0, 0, 0)
(that is black
). In our case, that's the text.
html, body { display: grid }
html { height: 100% }
body {
background: url('https://images.unsplash.com/photo-1700607687506-5149877683e8?w=1400') 50%/ cover fixed
}
svg[width='0'][height='0'] { position: fixed }
p {
place-self: center;
position: relative;
padding: 0 .125em;
width: min-content;
background: white;
font: 900 clamp(1.25em, 25vmin, 25em)/ 1.125 sans-serif;
text-align: center;
filter: url(#punch-through)
}
<svg width='0' height='0'>
<filter id='punch-through'>
<feColorMatrix values=' 1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
999 999 999 0 0'/>
</filter>
</svg>
<p contenteditable>Hello, world!</p>
Now you may be wondering what's up with those values in that formula, what's the deal with the matrix, how are they connected...
v v v 0 0
are the values on the fourth (final) row of the matrix values
for feColorMatrix
. v
is a large value, let's throw in a random 999
, in order to ensure the result alpha outside of the fully black
areas (that is, outside the text) is always ≥ 1
(which means fully opaque).
The other three rows give us the RGB channels and those we just want to leave unchanged. The red channel of the output is just 1
multiplied with red channel of the input plus a bunch of zeroes and the same is valid for the green and blue channels.
1·R + 0·G + 0·B + 0·A + 0 = R + 0 + 0 + 0 + 0 = R
0·R + 1·G + 0·B + 0·A + 0 = 0 + G + 0 + 0 + 0 = G
0·R + 0·G + 1·B + 0·A + 0 = 0 + 0 + B + 0 + 0 = B
This means the first three matrix rows are:
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
(1
diagonally and 0
elsewhere)
Now... depending on how picky you are, you might be happy with this result... or not!
I'm not happy with it.
There's still some black along the edges and it looks kinda rough.
Fortunately, in this particular case, the background
of our p
is white
, which means we're maxing out all three RGB channels at 100%
, that's our input for them, that's what we want the output to be, no need to even bother with even something as simple as multiplying the input channel values with 1
anymore!
Just drop the decimal representation (1
for 100%
) on the last column of the matrix and zero everything else for the first three rows, which basically means this is what gets computed (R
, G
, B
and A
are all 1
and that's something we know beforehand):
0·R + 0·G + 0·B + 0·A + 0 = 0·1 + 0·1 + 0·1 + 0·1 + 0 = 0 + 0 + 0 + 0 + 1 = 1
0·R + 0·G + 0·B + 0·A + 0 = 0·1 + 0·1 + 0·1 + 0·1 + 0 = 0 + 0 + 0 + 0 + 1 = 1
0·R + 0·G + 0·B + 0·A + 0 = 0·1 + 0·1 + 0·1 + 0·1 + 0 = 0 + 0 + 0 + 0 + 1 = 1
As for the final row, the one giving us the alpha of the result, it's the same as before, we just make v
equal to 1/3
since we know the background
RGB channels in this case are maxed out at 100%
(1
), so multiplying them with 1
and then adding up the products won't give us a subunitary value for the result alpha (1
and greater means fully opaque, in the (0, 1)
interval means semitransparent and 0
means fully transparent) outside the actual text, which is black
, so all channels are 0
.
So for the white
background, the alpha computes to:
v·R + v·G + v·B + 0·A + 0 = ⅓·1 + ⅓·1 + ⅓·1 + 0·1 + 0 = ⅓ + ⅓ + ⅓ + 0 + 0 = 3/3 = 1
while for the black
text, the alpha computes to:
v·R + v·G + v·B + 0·A + 0 = ⅓·0 + ⅓·0 + ⅓·0 + 0·1 + 0 = 0 + 0 + 0 + 0 + 0 = 0
Now... at the edges of the text, we have some pixels that are neither fully black
nor fully white
and those give us some semitransparency there to smoothen out the edges, make them look nice.
html, body { display: grid }
html { height: 100% }
body {
background: url('https://images.unsplash.com/photo-1700607687506-5149877683e8?w=1400') 50%/ cover fixed
}
svg[width='0'][height='0'] { position: fixed }
p {
place-self: center;
position: relative;
padding: 0 .125em;
width: min-content;
background: white;
font: 900 clamp(1.25em, 25vmin, 25em)/ 1.125 sans-serif;
text-align: center;
filter: url(#punch-through)
}
<svg width='0' height='0'>
<filter id='punch-through'>
<feColorMatrix values=' 0 0 0 0 1
0 0 0 0 1
0 0 0 0 1
.33 .33 .33 0 0'/>
</filter>
</svg>
<p contenteditable>Hello, world!</p>
But what if we don't want a white
background? No problem, we can easily set any other solid background
in the SVG filter
!
We just flood the entire filter
area with the desired background and give it the alpha of the punched through white
layer.
html, body { display: grid }
html { height: 100% }
body {
background:
url('https://images.unsplash.com/photo-1700607687506-5149877683e8?w=1400')
50%/ cover fixed
}
svg[width='0'][height='0'] { position: fixed }
p {
place-self: center;
position: relative;
padding: 0 .125em;
width: min-content;
background: white;
font: 900 clamp(1.25em, 25vmin, 25em)/ 1.125 sans-serif;
text-align: center;
filter: url(#punch-through)
}
<svg width='0' height='0'>
<filter id='punch-through'>
<feColorMatrix values=' 0 0 0 0 1
0 0 0 0 1
0 0 0 0 1
.33 .33 .33 0 0' result='punched'/>
<feFlood flood-color='darkslategrey'/>
<feComposite operator='in' in2='punched'/>
</filter>
</svg>
<p contenteditable>Hello, world!</p>
The semitransparent #2f4f4fa4
as a flood-color
works just as well.
We can even replace the white
background on our p
with something like:
repeating-linear-gradient(45deg, transparent 0 4px, white 0 2rem)
for a background
on our p
that has fully opaque and fully transparent parts - try it out!