Aranel
Aranel

Reputation: 114

Css only striped text-shadow effect

is there a clean and elegant way to achieve a vintage striped text-shadow effect?

To give you an example, i'd like to reproduce with css something like this:

enter image description here

I'd like the stripes to be of a different color from the font.

Upvotes: 4

Views: 3035

Answers (2)

Ana
Ana

Reputation: 37178

Seeing this almost a decade later, so let's see how things are today!

There are two options:

  1. A pure CSS one by clipping to text. A bit different from the one in the previous answer.

    Pros:

    • super easy to understand
    • no text duplication if we give up the transparency outline
    • can set the shadow offset relative to the font-size

    Cons:

    • can't reproduce effect exactly
    • no good, easy, standard, cross-browser way to get a real and fully correct transparency outline
  2. An SVG one using a filter to create the effect.

    Pros:

    • reproduces effect exactly
    • can cut out a real transparency outline around the text
    • great support
    • no text duplication

    Cons:

    • can't set the long shadow/ 3D extrusion offset to be relative to the font-size
    • not that simple due to the above mentioned scalability limitation and bugs when it comes to partial (not even full!) workarounds

Let's see each of them!

Pure CSS by clipping to text

We start by setting the striped background on our element and clipping it to text - this is the striped shadow. We then set a text-shadow offset towards the top left - text shadows are painted under the actual text (which we've made transparent in this case), but over the background (the stripes in our case), so this offset text-shadow is on top of the stripes and creates the main part of the letters.

The trick here is that the striped "shadow" is the actual text and the "main text" is actually created with text-shadow.

Note that background-clip: text is now standard and works unprefixed in the shorthand in all browsers (at least in all modern desktop browsers I've tested in, not sure about mobile as I don't have a smartphone).

@import url('https://fonts.googleapis.com/css2?family=Zilla+Slab:wght@700&display=swap');

html, body { display: grid }

html { height: 100% }

body { background: #e2e7e3 }

.striped {
  --m: 2; /* font size multiplier */
  --u: calc(var(--m)*1px*sqrt(2)); /* stripe unit */
  --o: -.05em; /* shadow offset */
  background: /* striped "shadow" is the actual text */
    repeating-linear-gradient(45deg, 
        #000 0 var(--u), #0000 0 calc(2*var(--u))) text;
  color: transparent; /* see through to stripes */
  font: 700 
    calc(var(--m)*clamp(1em, 30vmin, 10em))/ 1.125 
    zilla slab, serif;
  /* main text part is actually the shadow */
  text-shadow: var(--o) var(--o) darkslategrey;
  
  /* layout & prettifying styles */
  place-self: center;
  letter-spacing: .1em;
  text-align: center;
  text-transform: uppercase;
  text-wrap: balance
}

.allchar { --m: 1 }
<div class='striped example'>airbag</div>
<div class='striped allchar'>a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9 & ! @ £ $</div>

(the why behind that --u value)

This is simple and looks close enough, even if it's not the same - in your image, you actually have a hashed 3D extrusion, while this technique produces a hashed "shadow".

comparison between the A produced by our code and the one in the original image

a visual comparison

Now for the outline around the main part of the letters. Unfortunately, the only way I can think of making it transparent in pure CSS would involve duplicating the text (far from ideal) and using mask in two different ways in Firefox and Chrome, in a way similar to the one I've explained in a lot of detail a few years back in this article on emojicon glassmorphism.

In Firefox, we use element() (which is standard, but Chrome hasn't implemented this for security reasons and Firefox still needs the -moz- prefix) as a mask-image. In Chrome, we use the non-standard -webkit-mask-clip: text.

You can see how that would work in this CodePen demo - it's a lot of browser-specific code (30 CSS declarations in total, wrapped up in browser specific @supports), some of it not even standard, so I can't recommend using it and I'm not going to dump into a snippet here.

And even so, it's still not really a correct transparency outline - see the arrows below, we just don't have that transparent gap above the foot of the letter.

comparison between the A produced by our code and the one in the original image

a visual comparison

SVG filter solution

We start by dilating the text in all directions in order to get the area covered by that transparent outline around it. We do this with feMorphology using the dilate operator. This comes with a bunch of problems if we have a big radius value, but we only want a thin transparent outline, 1-2 pixels will do.

<feMorphology operator='dilate' radius='1.5'/>

Next up, we create the 3D extrusion effect towards the bottom right. This is done with an n×n identity matrix as the kernelMatrix for feConvolveMatrix (where the order must also be n) and then with an n/2-sized feOffset along both axes in the positive direction (towards the bottom right).

I normally use Pug to generate markup and that means we can easily generate the n×n identity matrix out of the n value too:

feConvolveMatrix(order=n 
                 kernelMatrix=(new Array(n*n)).fill(0) 
                                              .map((_, i) => 1*!(i%(n + 1))) 
                                              .join(' ') 
                 divisor='1')
feOffset(dx=.5*n dy=.5*n)

If you don't want to do that, the compiled HTML looks like this for n = 8:

<feConvolveMatrix order='8' 
                  kernelMatrix='1 0 0 0 0 0 0 0 
                                0 1 0 0 0 0 0 0 
                                0 0 1 0 0 0 0 0 
                                0 0 0 1 0 0 0 0 
                                0 0 0 0 1 0 0 0 
                                0 0 0 0 0 1 0 0 
                                0 0 0 0 0 0 1 0 
                                0 0 0 0 0 0 0 1' 
                  divisor='1'/>
<feOffset dx='4' dy='4'/>

Yeah, not pretty... that's why I use Pug!

Next, we subtract out of this the dilated text at step one and then add the initial text (SourceGraphic) on top of it all.

... and we have text with a real transparency outline and 3D extrusion!

Note that since we don't use the SVG for anything other than the filter, we can take it out of the document flow.

@import url('https://fonts.googleapis.com/css2?family=Zilla+Slab:wght@700&display=swap');

html, body { display: grid }

html { height: 100% }

body {
  background: 
    url('https://images.unsplash.com/photo-1700607687506-5149877683e8?w=1400') 
      50%/ cover fixed
}

svg[height='0'] { position: fixed } /* out of document flow */

.text {
  --m: 2; /* font size multiplier */
  color: darkslategrey;
  font: 700 
    calc(var(--m)*clamp(1em, 28vmin, 10em))/ 1.125 
    zilla slab, serif;
  filter: url(#f);
  
  /* layout & prettifying styles */
  place-self: center;
  letter-spacing: .1em;
  text-align: center;
  text-transform: uppercase;
  text-wrap: balance
}

.allchar { --m: 1 }
<svg width='0' height='0'>
  <filter id='f'>
    <feMorphology in='SourceAlpha' operator='dilate' radius='1.5' result='dilate'/>
    <feConvolveMatrix order='8' 
                      kernelMatrix='1 0 0 0 0 0 0 0 
                                    0 1 0 0 0 0 0 0 
                                    0 0 1 0 0 0 0 0 
                                    0 0 0 1 0 0 0 0 
                                    0 0 0 0 1 0 0 0 
                                    0 0 0 0 0 1 0 0 
                                    0 0 0 0 0 0 1 0 
                                    0 0 0 0 0 0 0 1' 
                      divisor='1'/>
    <feOffset dx='4' dy='4'/>
    <feComposite in2='dilate' operator='out'/>
    <feBlend in='SourceGraphic'/>
  </filter>
</svg>

<div class='text example'>airbag</div>
<div class='text allchar'>a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9 & ! @ £ $</div>

Wait, what? Where are the stripes? Well, we just haven't added them yet.

Before moving further, note how cutting the dilated version of the letters out (before adding their original version back in) has left us with a real transparent outline around the letters.

screenshot highlighting that we have a real transparent outline around the letters

Now let's see about those stripes!

If we want any control or flexibility when it comes to easily controlling/ tweaking their angle and thickness, we need to add those stripes from the CSS.

We need to do that because, as you can see above, the size of the 3D extrusion is always the same, regardless of the letter size.

This is because here we're hitting the unscalable part of SVG. Those numbers in SVG attributes? The dilation radius, the offsets... they're fixed px values!

But isn't the 'S' in "SVG" supposed to stand for "Scalable"?

Well, yeah, and SVG filters do have a primitiveUnits attribute that can be set to objectBoundingBox and in that case, those same values become relative to the dimensions of the box we're setting the filter on. But that isn't in any way better for this use case.

We want to have the same offset along both axes and with primitiveUnits set to objectBoundingBox, we cannot have this unless the aspect-ratio of the box containing our text is always the same. Which is not the case for both our text boxes.

screenshot highlighting the boundaries of our text boxes

Furthermore, since offsets are relative to box dimensions, this means that the bigger the box, the bigger the offset. But the bigger box in our case is the one with more, but smaller text... for which we want to have a smaller offset!

At this point, when responsivity is so important, SVG got left behind - after all, CSS drop-shadow() or blur() filters allow us to use length values that are relative to the font-size of the element they're applied on, so I think SVG should too. But right now, it doesn't.

At the moment, the only way we can get differently sized extrusion for different font sizes... is to create a separate filter for each font size. Far from ideal.

So let's see what we can do to at least control stripe thickness from the CSS!

The way that we do this is by passing the text and the CSS-created/ controllable stripes to the SVG, each of them in one of the RGB channels. Yes, you heard that right!

SVG filters allow us to extract just one channel out of the SourceGraphic, so we pass the text shape to the SVG in the blue B channel and the stripes in the red B channel.

The easiest way to do this is to make the text transparent, set two background layers, one fully blue clipped to text plus one with red stripes covering the entire box and then blend them using the lighten blend mode.

Blending is an operation that works pixel by pixel. We apply the blending operation separately for every pixel in the pixel grid of the two blended layers.

Illustration showing two corresponding pixels of the two layers being blended, which results in the corresponding pixel of the resulting layer.

how blending works

lighten is a separable blend mode, which means it's also applied separately for each channel.

So for a generic pixel on row j and column i in the pixel grid, we take the generic channel C₁ of the top layer and the corresponding channel C₀ in the bottom layer and the maximum (lighter) between the two is the resulting channel of the result pixel when applying the lighten blend mode.

Let's say the top later is the blue text layer and the bottom layer is the red stripes layer.

First take the top layer. Outside the text, we have transparency (rgba(0, 0, 0, 0)). The text itself is opaque blue, so rgb(0, 0, 255).

Then take the bottom layer. In between the stripes, we have black, so that's rgb(0, 0, 0). The stripes themselves are red, so rgb(0, 0, 0).

Where the area outside the text (rgba(0, 0, 0, 0)) intersects the black (rgb(0, 0, 0)) area in between the stripes, we have the following for the three RGB channels:

max(R₁, R₀) = max(0, 0) = 0
max(G₁, G₀) = max(0, 0) = 0
max(B₁, B₀) = max(0, 0) = 0

So the result is black, rgb(0, 0, 0).

Where the area outside the text (rgba(0, 0, 0, 0)) intersects the red (rgb(0, 0, 255)) stripes, we have:

max(R₁, R₀) = max(0, 255) = 255
max(G₁, G₀) = max(0, 0) = 0
max(B₁, B₀) = max(0, 0) = 0

So the result is red, rgb(255, 0, 0).

Where the blue text itself (rgb(0, 0, 255)) intersects the black (rgb(0, 0, 0)) area in between the stripes, we have:

max(R₁, R₀) = max(0, 0) = 0
max(G₁, G₀) = max(0, 0) = 0
max(B₁, B₀) = max(255, 0) = 255

So the result is blue, rgb(0, 0, 255).

Where the blue text itself (rgb(0, 0, 255)) intersects the red (rgb(0, 0, 255)) stripes, we have:

max(R₁, R₀) = max(0, 255) = 255
max(G₁, G₀) = max(0, 0) = 0
max(B₁, B₀) = max(255, 0) = 255

So the result is rgb(255, 0, 255) (which is magenta or fuchsia... don't even ask, those names are one huge mess).

div {
  --u: calc(.0625em*sqrt(2));
  background: 
    linear-gradient(#00f 0 0) text, 
    repeating-linear-gradient(-45deg, 
        #f00 0 var(--u), #000 0 calc(2*var(--u)));
  background-blend-mode: lighten;
  color: #0000; /* same as transparent, just fewer char */
  font: 900 7em sans-serif;
  text-transform: uppercase;
}
<div>reindeer</div>

Cool, right?

Except for the fact that... if you happen to check this in Firefox or Safari, you may notice that it doesn't work.

This is what the result should look like:

the blended result as described by the process above

But Firefox is buggy when it comes to having multiple layers out of which only a part are clipped to text (bug 1481498) and Safari fails to blend background layers when at least one of them is clipped to text (bug 267129).

So... unfortunately, we need to add a pseudo.

We leave the text blue, create an absolutely positioned pseudo covering the entire text box, set pointer-events: none to allow selection, right-click and all the goodies on the text. And then blend this pseudo with the text.

div {
  --u: calc(.0625em*sqrt(2));
  position: relative;
  color: #00f;
  font: 900 7em sans-serif;
  text-transform: uppercase
}

div::after {
  position: absolute;
  inset: 0;
  background: 
    repeating-linear-gradient(-45deg, 
        #f00 0 var(--u), #000 0 calc(2*var(--u)));
  mix-blend-mode: lighten;
  pointer-events: none;
  content: ''
}
<div>reindeer</div>

This works cross-browser!

Oh, and we should either set a fully black (rgb(0, 0, 0)) background on the element or isolation: isolate in order to prevent blending the pseudo with any backdrop that may be seen through its parent's transparent background... which would result in something like this if we have an image background on the body:

blending with body backdrop

Okay... back to our SVG filter!

We feed it something like this as the SourceGraphic input.

Extract the text from the blue channel with feColorMatrix. For the default type of matrix, the values attribute takes a space-separated list of 20 values used to compute the resulting RGBA channels in groups of 5 for each (first 5 used to compute the output R channel, next 5 the output G channel and so on...). We also want to take this opportunity to paint our text however we wish, let's say in the same darkslategrey (or rgb(47, 79, 79) or rgb(18%, 31%, 31%) in percentage RGB).

Now for each of the 4 channels, its corresponding 5 values from the matrix (call them v₀, v₁, v₂, v₃, v₄) are multiplied with the input RGBA channels (more exactly, using the decimal representation of percentage RGBA) and with 1.

R*v₀ + G*v₁ + B*v₂ + A*v₃ + 1*v₄

Now since we want to paint our text as we please, for the first three batches of 5 values, we take v₀, v₁, v₂ and v₃ to be 0 (meaning that the input RGBA channels don't matter for the output RGB) and the final value, v₄, the corresponding decimal representation of the percentage RGB for darkslategrey (that is, .18 for the v₄ of the first batch and .31 for the second and third).

As for the alpha channel, that's 1 within the text (where the B channel is 1) and 0 elsewhere, so for the last batch of 5 values, we have that v₂ (which gets multiplied with the B channel) is 1 and the rest are 0.

So this is what our feColorMatrix looks like:

<feColorMatrix values='0 0 0 0  .18 
                       0 0 0 0  .31 
                       0 0 0 0  .31 
                       0 0 1 0 0' result='basetext'/>

We save this result as 'basetext' then dilate it, extrude it, offset it, subtract the dilation as before and save this result as extruded.

We then extract the red stripes from the SourceGraphic exactly how we extracted the blue text (the output A channel is 1 only where the input R is 1, so for the final batch of 5 values, we take v₀ to be 1, while all other values are 0) and paint it in a very dark grey (let's say rgb(7%, 7%, 7%)).

<feColorMatrix values='0 0 0 0  .07 
                       0 0 0 0  .07 
                       0 0 0 0  .07 
                       1 0 0 0 0'/>

We then only keep these dark grey stripes where they are in the area of the extruded result.

And finally, we put the base text on top. And that's it!

@import url('https://fonts.googleapis.com/css2?family=Zilla+Slab:wght@700&display=swap');

html, body { display: grid }

html { height: 100% }

body {
  background: 
    url('https://images.unsplash.com/photo-1700607687506-5149877683e8?w=1400') 
      50%/ cover fixed
}

svg[height='0'] { position: fixed }

.striped {
  --m: 2; /* font size multiplier */
  --u: calc(var(--m)*1px*sqrt(2));
  position: relative;
  color: blue;
  font: 700 
    calc(var(--m)*clamp(1em, 28vmin, 10em))/ 1.125 
    zilla slab, serif;
  isolation: isolate;
  filter: url(#f);
  
  /* layout & prettifying styles */
  place-self: center;
  letter-spacing: calc(var(--m)*.05em);
  text-align: center;
  text-transform: uppercase;
  text-wrap: balance;
    
  &::after {
    position: absolute;
    inset: 0;
    background: 
      repeating-linear-gradient(45deg, 
          #f00 0 var(--u), #000 0 calc(2*var(--u)));
    mix-blend-mode: lighten;
    content: ''
  }
}

.allchar { --m: 1 }
<svg width='0' height='0'>
  <filter id='f' color-interpolation-filters='sRGB'>
    <feColorMatrix values='0 0 0 0  .18 
                           0 0 0 0  .31 
                           0 0 0 0  .31 
                           0 0 1 0 0' result='basetext'/>
    <feMorphology operator='dilate' radius='1.5' result='dilate'/>
    <feConvolveMatrix order='8' 
                      kernelMatrix='1 0 0 0 0 0 0 0 
                                    0 1 0 0 0 0 0 0 
                                    0 0 1 0 0 0 0 0 
                                    0 0 0 1 0 0 0 0 
                                    0 0 0 0 1 0 0 0 
                                    0 0 0 0 0 1 0 0 
                                    0 0 0 0 0 0 1 0 
                                    0 0 0 0 0 0 0 1' 
                      divisor='1'/>
    <feOffset dx='4' dy='4'/>
    <feComposite in2='dilate' operator='out' result='extruded'/>
    <fecOlorMatrix in='SourceGraphic' 
                   values='0 0 0 0  .17 
                           0 0 0 0  .17 
                           0 0 0 0  .17 
                           1 0 0 0 0'/>
    <feComposite in2='extruded' operator='in'/>
    <feBlend in='basetext'/>
  </filter>
</svg>
<div class='striped example'>airbag</div>
<div class='striped allchar'>a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9 & ! @ £ $</div>

Note that when we do such feColorMatrix manipulation, we need to also set color-interpolation-filters to sRGB.

Upvotes: 9

szupie
szupie

Reputation: 856

http://jsfiddle.net/kntz6h01/2/

This is the closest I could come up with, but it only works with webkit browsers and is not very customisable in terms of the length of the text-shadow. It also requires an attribute in the tag to duplicate the text.

Basically it works by creating a striped background with -webkit-repeating-linear-gradient, masking out the background with

-webkit-background-clip: text;
-webkit-text-fill-color: transparent;

and duplicating the text with the :after pseudo-element and applying a text-shadow to it.

It's probably not very useful.

Upvotes: 2

Related Questions