Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Wed, 01 Oct 2025 16:05:08 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 Inset Shadows Directly on img Elements (Part 1) https://frontendmasters.com/blog/inset-shadows-directly-on-img-elements-part-1/ https://frontendmasters.com/blog/inset-shadows-directly-on-img-elements-part-1/#respond Wed, 01 Oct 2025 15:59:05 +0000 https://frontendmasters.com/blog/?p=7213 You might think the job of putting an inset shadow on an <img> is trivial: just set a box-shadow like inset 0 1px 3px and that’s it!

You’d be wrong.

This doesn’t work because the actual image is content for the img element. And content is painted on top of box-shadow.

This problem is something that has been the topic of countless questions on Stack Overflow as well as Reddit and other places on the internet. It has also been covered in many articles over the past 15 years. Now in 2025, it still made the list of pain points when dealing with shapes & graphics according to the State of CSS survey.

So why yet another article? Well, almost all the solutions I’ve seen so far involve at least another element stacked on top of the img (assuming they don’t straight up replace the img with a div), so that we can have a “cover” with the exact dimensions on top – this is the (pseudo)element that actually gets the inset shadow. Beyond using at the very least an extra pseudo-element for each image, this can be annoying for users, as the right click img menu is lost unless the “cover” gets pointer-events: none.

I want to show you a solution that allows us to add the shadow directly on <img> elements without requiring an extra wrapper or sibling for each.

This article is going to have two parts, the first (current one) going into a lot of detail about the how behind creating the basic inset black shadow with offsets, blur and spread radii and the second being a deep dive into pain points like painting the shadow and limitations tied to length values.

Base setup

We have just an img element:

<img src='my-image.jpg' alt='image description'>

And a simple SVG filter:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
  </filter>
</svg>

Wait, don’t run away screaming!

I promise that, while SVG filters may seem scary and this technique has some limitations and quirks, it’s still easy to digest when going through it step by step, each step having interactive demos to help with understanding how things work in the back. By the end of it, you’ll have a bunch of cool new tricks to add to your web dev bag.

So let’s get started!

First off, our SVG filter needs to be inside an svg element. Since this element only exists to contain our filter, it is not used to display any graphics, it is functionally the same as a style element. So we zero its dimensions, hide it from screen readers and take it out of the document flow from the CSS:

svg[height='0'][aria-hidden='true'] { position: fixed }

We then apply our filter on the img element:

img { filter: url(#si) }

Note that the filter as it is at this point causes the img to disappear in Firefox, even as it leaves it unchanged in Chrome. And, according to the spec, an empty filter element means the element the filter applies to does not get rendered. So Firefox is following the spec here, even if the Chrome result is what I would have expected: an empty filter being equivalent to no filter applied.

The base filter content

Offset the alpha map

We start off by offsetting the alpha map of the filter input, the filter input being our img in this case. The alpha map is basically the filter input where every single pixel has its RGB channels zeroed and its alpha channel preserved.

Since here the filter input is a plain rectangular, fully opaque image, the alpha map (referenced within the SVG filter as SourceAlpha) is a fully opaque black rectangle within the boundary of our initial image, while everything around it is fully transparent. Note that if the img has a border-radius (with any kind of corner-shape), then the alpha map is going to respect that too.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feOffset in='SourceAlpha' dx='9' dy='13'/>
  </filter>
</svg>

These fe-prefixed elements inside our filter (“fe” stands for “filter effect”) are called filter primitives. They may have zero, one, or two inputs. Primitives with zero inputs create a layer based on their other attributes (for example, feTurbulence can give us a noise layer based on a baseFrequency attribute). Primitives with one input (like feOffset here) modify that input. And finally, primitives with two inputs combine them into one result (for example, feBlend blends its two inputs using the blend mode given by its mode attribute).

All of those needed for the base filter creating a simple inset black shadow have either one or two, though when we get to painting the shadow and other effects, we may need to use some with no inputs.

For most of those with a single input, we don’t specify that input explicitly (by setting the in attribute) because we’re using the defaults! Filter primitive inputs are by default the result of the previous primitive or, in the case of the very first primitive, the filter input (referenced within the SVG filter as SourceGraphic).

feOffset in particular offsets its input along the x and/or y axis. In our particular case, it offsets its input by 9px along the x axis and by 13px along the y axis.

The following interactive demo illustrates how this primitive works and allows changing the feOffset attributes to see how that affects the visual result.

Note that the in attribute and the offset ones (dx and dy) are greyed and crossed out when set to SourceGraphic and 0 respectively. It’s because these are the default values and if they are the values we want for them, then we don’t need to set them at all.

Blur the offset map

Next, we blur this offset result.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feOffset in='SourceAlpha' dx='9' dy='13'/>
    <feGaussianBlur stdDeviation='5'/>
  </filter>
</svg>

Adding this second primitive is equivalent to chaining blur(5px) after the filter we had at the previous step (with only the feOffset primitive).

Note that this blur radius (and any SVG blur radius in general, whether it’s a stdDeviation attribute of an SVG filter primitive or a blur radius used by CSS equivalents like the blur() or drop-shadow() functions) needs to be half the one we’d use for a box-shadow if we want the same result. You can check out this article by David Baron for a detailed explanation of the why behind.

The interactive demo below lets us play with the filter we have so far (all primitive attributes can be changed) in order to get a better feel for how it works.

Note that these first two primitives can be in any order (we get the exact same result if we apply the offset after the blur). However, this is generally not the case — in most cases, the order of the primitives does matter.

Also note that in some scenarios (for example if we increase the blur radius to the maximum allowed by the demo), the blur seems cut off from a certain point outside the input element’s boundary. This cutoff is where the filter region ends.

Screenshot of the previous interactive demo where the interactive code panel can control the the visual result, in the case when the stdDeviation value of the feGaussianBlur primitive is bumped up to 32. In this situation, the blur of the black rectangle doesn't fade to full transparency. Instead, it gets abruptly cut off not far outside the initial boundary of the filter input image.

By default, the filter region extends 10% of the filter input’s bounding box size in every direction. In the case of a rectangular image, the bounding box is the image rectangle, the one whose boundary is marked by a dashed line in the interactive demos above.

We can change this region by changing the x, y, width and height attributes of the filter element. By default, these are given relative to the width and height of the filter input’s bounding box, using either a percentage or decimal representation. We could change the value of the filterUnits attribute to make them fixed pixel values, but I don’t think that’s something I’ve ever wanted to do and the default of them being relative to the filter input’s bounding box is what we want here, too.

For example, x='-.25'and x='-25%' are both valid and produce the same result. In this case, the filter region starts from 25% of the input bounding box width to the left (negative direction) of the left edge of this bounding box. The interactive demo below allows toying with the filter region too.

However, since our desired effect, the basic inset shadow, is limited to the area of the filter input (that is, the area of the original image), we don’t care if anything outside it gets cut off by the filter region limit, so we won’t be touching these filter attributes. At least for now, as long as we’re talking just about the base inset shadow.

Subtract offset & blurred version from initial one

The next step is to subtract the alpha of this offset and blurred result from the original alpha map (SourceAlpha) with no offset or blur applied:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feOffset in='SourceAlpha' dx='9' dy='13'/>
    <feGaussianBlur stdDeviation='5'/>
    <feComposite in='SourceAlpha' operator='out'/>
  </filter>
</svg>

feComposite is a primitive with two inputs (in and in2, both defaulting to the result of the previous primitive). When we use feComposite with the operator attribute set to out, we subtract the second input (in2, not set explicitly here as we want it to be the result of the previous primitive) out of the first one (in).

This isn’t plain clamped subtraction. Instead, it’s similar to what subtract (source-out in the ancient, non-standard WebKit version) does when compositing alpha mask layers: the alpha of the first input in (α) is multiplied with the complement of the second input in2 alpha (α₂).

This means that for every pair of corresponding pixels from the two inputs, the RGB channels of the result are those of the pixel from the first input (in), while the alpha of the result is given by the following formula:

α·(1 – α₂)

Where α is the alpha of the pixel from the first input in, and α₂ is the alpha of the corresponding pixel from the second input in2, the input we subtract out of the first to get a black inset shadow.

Note that this latest interactive demo disables the option to switch between SourceAlpha and SourceGraphic inputs for the feOffset primitive. This is due to a Firefox bug which we might hit in certain situations and which makes the result of the feComposite simply disappear if feOffset uses the default SourceGraphic input.

Switching the operator also isn’t enabled here, as it would mean just too much to unpack and most is outside the scope of this article anyway. Just know that some of the operator values work exactly the same as their CSS mask-composite equivalents.

For example, over is equivalent to add (source-over in the ancient, non-standard WebKit version), subtracting the alpha product from their sum (α + α₂ - α·α₂).

Then in is equivalent to intersect (source-in), multiplying the alphas of the two inputs (α·α₂).

And xor is equivalent to exclude, where we add up the result of each of the two inputs being subtracted from the other (α·(1 – α₂) + α₂·(1 – α)).

For more details and visual examples illustrating how these operators work, you can check out this page (note that all operator values used for feComposite are source-* ones, for the effect given by the destination-* ones, we need to reverse the two inputs).

Place the initial image underneath

Now that we have the shadow, all we still need to do is place the filter input (the image in our case) underneath it. I’ve often seen this done with feMerge or feComposite. I personally prefer to do it with feBlend as this primitive with the default mode of normal produces the exact same result as the other two. Plus, other modes may offer us even more visually interesting results.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feOffset in='SourceAlpha' dx='9' dy='13'/>
    <feGaussianBlur stdDeviation='5'/>
    <feComposite in='SourceAlpha' operator='out'/>
    <feBlend in2='SourceGraphic'/>
  </filter>
</svg>

Just like feComposite, feBlend takes two inputs. in is the one on top and we don’t need to set it explicitly here, as it defaults to the result of the previous primitive, the inset shadow in our case. This is exactly the layer we want to have on top here. in2 is the one at the bottom and we set it to the filter input (SourceGraphic), which is the image in our case.

A base case example

This is exactly the technique we used to create the inner shadows on these squircle-shaped images.

A grid of squircle-shaped images with inner shadows.
squircle-shaped images with inset shadows (live demo)

Note that the squircle shape seems to be incorrect in Safari (tested via Epiphany on Ubuntu), but the relevant part (the inset shadow) seems to work well everywhere. Also, nowadays, this is not the simplest way to create squircle shapes anymore with the corner-shape property as well as the shape() function making their way into browsers, but it’s still a way to do it and, leaving aside bugs like the incorrect squircle Safari issue, a better supported one.

Spread it out: how to get a spread radius this way

The box-shadow property also allows us to control a fourth length value beside the offsets and the blur radius: the spread radius. To get the same effect with an SVG filter, we either erode or dilate the alpha map (SourceAlpha) using feMorphology.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feMorphology in='SourceAlpha' operator='dilate' radius='5'/>
  </filter>
</svg>

When using the dilate operator, what feMorphology does is the following: for every channel of every pixel, it takes the maximum of all the values of that channel for the pixels lying within the specified radius (from the current pixel) along both the x and the y axes in both the negative and positive direction.

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

how dilation works in the general case

In our case, things are made simpler by the fact that the input of this primitive is the alpha map of the filter input (SourceAlpha). Each and every single one of its pixels has the RGB values zeroed, so it basically doesn’t change anything on the RGB channels. The only thing that changes is the alpha channel at the edges, which is again made simpler by the fact that our input is a rectangular box (the alpha is 1 within the rectangle boundary and 0 outside), so for a radius of 1, our black box grows by 1px in every one of the four directions (top, right, bottom, left), for a radius of 2 it grows by 2px in every direction and so on.

When using the erode operator, feMorphology takes the minimum of all the values of each channel for the pixels lying within the specified radius (from the current pixel) along both the x and the y axes in both the negative and positive direction.

Below, you can see a similar recording to the dilation one, only this time it’s for erosion. For every pixel of the input (green outline around the current one), the corresponding output value for the same channel is the minimum of all the values for that channel within a radius of 1 from the current pixel (within the red square).

how erosion works in the general case

So in our particular case, erosion means that for a radius of 1, our black box shrinks by 1px in every one of the four directions (top, right, bottom, left), for a radius of 2 it shrinks by 2px in every direction and so on.

Just for fun, the interactive demo above allows switching between SourceAlpha and SourceGraphic. This is completely irrelevant in the context of this article, but it was a little low effort extra that allows seeing the effect of this primitive on the RGB channels too.

Since erode is a min() result where the lowest channel value wins, this operation darkens our input. Since dilate is a max() result where the highest channel value wins, this operation brightens our input. Also, they both create squarish shapes, which makes sense given the how behind, illustrated in the videos above. Basically, in the dilate case, every pixel brighter than those around it expands into a bigger and bigger square the more the radius increases; and in the erode case, every pixel darker than those around it expands into a bigger and bigger square as the radius increases.

So if we introduce this feMorphology primitive before all the others in our inset shadow filter (keep in mind this also means removing the in='SourceAlpha‘ attribute from feOffset, as we want the feOffset input to be the feMorphology result and, if we don’t explicitly set the in attribute, it defaults to the result of the previous primitive), it’s going to allow us to emulate the spread radius CSS provides for box-shadow.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feMorphology in='SourceAlpha' operator='dilate' radius='3'/>
    <feOffset dx='9' dy='13'/>
    <feGaussianBlur stdDeviation='5'/>
    <feComposite in='SourceAlpha' operator='out'/>
    <feBlend in2='SourceGraphic'/>
  </filter>
</svg>

Note that here we may also change the order of the feMorphology and feOffset primitives before the feGaussianBlur one and still get the same result, just like we may also change the order of the feOffset and feGaussianBlur primitives after the feMorphology one. However, the feMorphology primitive needs to be before the feGaussianBlur one, as having the feGaussianBlur primitive before the feMorphology one would give us a different result from what we want.

Unlike the CSS spread radius used by box-shadow, the radius attribute can only be positive here, so the operator value makes the difference, each of the two giving us a result that’s equivalent to either a positive CSS spread radius or a negative one.

Since the dilated/eroded, offset and blurred alpha map is subtracted (minus sign) out of the initial one for an inset shadow, the erode case corresponds to a positive spread radius, while the dilate case corresponds to a negative one.

If we were to use a similar technique for an outer shadow, where the dilated/eroded, offset and blurred alpha map would be the shadow itself, wouldn’t be subtracted out of anything (so plus sign in this case), the erode case would correspond to a negative spread radius and the dilate case to a positive one.

A fancier example

We can take it one step further and not only have an inner shadow with a spread, but also add a little touch that isn’t possible on any element with CSS alone: noise! This is done by displacing the inset shadow using a noise map, similar to how we create grainy gradients.

A grid of squircle-shaped images with grainy inner shadows that also have a spread.
squircle-shaped images with inset shadows with spread and grain (live demo)

]]>
https://frontendmasters.com/blog/inset-shadows-directly-on-img-elements-part-1/feed/ 0 7213
Replace Your Animated GIFs with SVGs https://frontendmasters.com/blog/replace-your-animated-gifs-with-svgs/ https://frontendmasters.com/blog/replace-your-animated-gifs-with-svgs/#comments Mon, 15 Sep 2025 16:12:53 +0000 https://frontendmasters.com/blog/?p=7112 ` or `background-image`, making it a viable GIF replacement if you can pull it off! ]]> No one loves dancing hamster GIFs more than I do. But all those animated frames can add up to files so large you don’t even see the dancing hamster. Your other tab has already loaded and you’ve followed the dopamine hits down another social media rabbit hole.

There’s an alternative for those giant animated GIFs: animated SVGs.

Along with much smaller file size you also get infinite scalability and the use of some — though, sadly, not all — media queries. Let’s take a look.

Warning: some of the animations in this article do not use a prefers-reduced-motion media query. We’ll discuss why that is later in the article.

How it works

First let’s create a simple rhombus in SVG. You could do a square, but rhombus is more fun to say.

<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 500 500">
  <path id="rhombus" fill="#fc0000" d="m454 80-68 340H46l68-340h340Z"/>
</svg>

Next let’s do a quick spinning motion that we’ll run infinitely.

#rhombus {
  transform-origin: center;
  rotate: 0deg;
  animation: spinny-spin 3.5s forwards infinite ease-in-out;
}
@keyframes spinny-spin {
  0% {
    rotate: 0deg;
  }
  90%, 100% {
    rotate: 720deg;
  }
}

We’ve done this as essentially a separate CSS file that looks into the SVG to style parts of it. We could pluck up that CSS and put it right inside the <svg> if we wanted. SVG is cool with that.

<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round"
    stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 500 500">
    <style>
        #rhombus {
            transform-origin: center;
            rotate: 0deg;
            animation: spinny-spin 3.5s forwards infinite ease-in-out;
        }
        @keyframes spinny-spin {
            0% { rotate: 0deg; }
            90%, 100% { rotate: 720deg; }
        }
    </style>
    <path id="rhombus" fill="#fc0000" d="m454 80-68 340H46l68-340h340Z" />
</svg>

Now that the SVG is all one contained thing, we could save it as an independent file (let’s call it rhombus.svg) and load it using an <img> element:

<img src="rhombus.svg" alt="a spinning red rhombus">

Even when loaded in an img, the animation still runs (!):

This is why this technique is viable as a .gif replacement.

This technique works best for animations that move and/or transform the elements as opposed to a sprite-based or successive image animation (which is basically what an animated GIF is). Also, for security reasons, an SVG loaded through an img element can not load external files i.e., the sprite image. You could base64 the sprite and embed it, but it would likely increase the file size to animated GIF levels anyway.

Let’s look at a more complicated example:

Here’s a zombie playing an accordion (yes, it’s random, unless you know about my silly little site, then it’s still random, but not unexpected). On the left is the GIF version. On the right is the SVG.

GIF
SVG

As an animated GIF, this polka-playing image is about 353Kb in size, but as an animated SVG it’s just 6Kb, less than 2% of the GIF’s size. That’s massive size (performance) savings with the SVG, while looking crisper doing it.

I drew the character in a graphics program and outputted it as an SVG. I used Affinity Designer but you could use Adobe Illustrator, Inkscape, Figma, or anything else that exports SVG.

Side Note: In my export, I made certain to check the “reduce transformations” box in order to make it easier to animate it. If you don’t reduce transformations, the elements can appear in all kinds of cockamamie contortions: scaled, translated and rotated. This is fine if the element is static, but if you want to move it in any way with transformations, you’ll have to figure out how editing the transformations will affect your element. It almost certainly won’t be straightforward and may not even be decipherable. With reduced transformations, you get an element in its natural state. You can then transform it in whatever way you need to.

After outputting, I created the CSS animation using @keyframes then added that to an SVG style element (which works just about exactly the same as an HTML style element).

See Complete SVG File
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 20 250 440">
  <style>
    /* Music Note Animation */
    .musicnote {
      animation: 4s ease-in-out calc(var(--multiplier) * 1s - 1s) notes forwards infinite;
      opacity: 0;
    }
    /* These custom properties allow for some variation in the timing of each note so that there animations overlap and seem more random while also allowing one set of keyframes to be used for all seven notes */
    #n1 { --multiplier: 1 }
    #n2 { --multiplier: 1.2 }
    #n3 { --multiplier: 1.4 }
    #n4 { --multiplier: 1.6 }
    #n5 { --multiplier: 1.8 }
    #n6 { --multiplier: 2 }
    #n7 { --multiplier: 2.2 }
    @keyframes notes {
      /* move the notes up 2em while also varying their opacity */
      0% {
        opacity: 0;
        transform: translateY(0);
      }
      30%, 80% {
        opacity: 1;
      }
      100% {
        opacity: 0;
        transform: translateY(-2em);
      }
    }
    #zright, #zleft {
      /* Sets the initial state for each hand and side of the accordion */
      --multiplier: -1;
      transform-origin: 0 0;
      transform: translateX(0) rotate(0);
      animation: 4s ease-in-out 0s play forwards infinite;
    }
    #zleft {
      /* allows the same keyframes to be used for both sides by reversing the translation and rotation */
      --multiplier: 1;
    }
    @keyframes play {
      0%, 100% {
        transform: translateX(0) rotate(0);
      }
      50% {
        transform: translate(calc(var(--multiplier) * 31px), calc(var(--multiplier) * -1px)) rotate(calc(var(--multiplier) * 2deg));
      }
    }
    /* Animates the squeeze and stretch of the accordion bellows */
    #accord {
      animation: 4s linear 0s squeeze forwards infinite;
      transform-origin: center center;
      transform: scaleX(1);
    }
    @keyframes squeeze {
      0%, 100% {
        transform: scaleX(1);
      }
      50% {
        transform: scaleX(0.8);
      }
    }
  </style>
<g id="zombie">
  <!-- The main zombie head and body, everything except the hands -->
  <path fill="#676767" fill-rule="nonzero" d="M62 207h121v47H62z" />
  <path fill="#91c1a3" fill-rule="nonzero" d="M99 190h46v26H99z" />
  <path fill="#3a3a3a" fill-rule="nonzero" d="M156 87h10v19H78V87h9v-9h69v9Z" />
  <path fill="#9cd3b3" fill-rule="nonzero"
    d="M155 105h9v18h19v29h-10v27h-9v9h-9v10h-18v9h-29v-9H90v-10H80v-9h-9v-27h-9v-29h18v-18h10v-9h65v9Z" />
  <path id="eyes" fill="#fbeb8e" fill-rule="nonzero" d="M127 114h31v28h-31zm-37 0h28v28H90z" />
  <path fill="#758b7c" fill-rule="nonzero" d="M108 170h11v9h-11z" />
  <path fill="#91c1a3" fill-rule="nonzero" d="M118 133h9v28h-9z" />
  <path fill="#444445" fill-rule="nonzero" d="M90 123h9v9h-9zm46-9h9v9h-9z" />
  <path fill="#3a3a3a" fill-rule="nonzero" d="M164 102h9v39h-9zm-93 0h9v39h-9z" />
  <path fill="#676767" fill-rule="nonzero" d="M118 393v57H46v-37h34v-58h38v38Z" />
  <path fill="#9cd3b3" fill-rule="nonzero" d="M80 384h38v10H80z" />
  <path fill="#676767" fill-rule="nonzero" d="M128 393v-38h38v58h34v37h-72v-57Z" />
  <path fill="#9cd3b3" fill-rule="nonzero" d="M128 384h38v10h-38z" />
</g>
  <g id="accord">
<!-- THe accordion bellows -->
    <path fill="#9e6330"
      d="m191 201-20 7-25 7-20 4-25-2-25-7-24-3-14-2-18 147 23 9 24 6 29 5 30 4 25-6 27-8 29-1 17-9-19-152-14 1Z" />
    <path fill="#774b24"
      d="m107 214-10-1-6 162 10 1 6-162Zm14 2h10v162h-10zm31-5-10 1 4 162 10-1-4-162Zm23-6h-10l7 162h10l-7-162Zm20-8-10 1 17 166 10-1-17-166ZM81 208l-10-1-10 162h10l10-161Zm-20-5-10-1-16 162 10 1 16-162Z" />
  </g>
<g id="notes">
  <!-- The seven musical notes -->
  <path id="n7" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m200 153 5-23 2 1 2 1v1h2l-1 1h2l4 1 1 1v1h2l-1 2h1v1l2 1h1l-1 2-1-1v1h-4l1-1h-2v-1h-4l-1 1-3-1v-1h-2l-4 19h-1v2h-1l-1 1-1 1-2-1v1h-3v-1l-2-1v-1h-1l1-1h-1v-1h6v-1h2v-1h3v-1h-5v1h-2v-1l-1 1v-1l-3 1h-1l1-2h1v-2h1v-1h1v-1l2 1 1-1h3l-1 1 3 1-1 1h1Z" />
  <path id="n6" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m16 78 3-23h2v1h3v1h1v1h6v1h1v2h2l-1 2h1v1h2v1h1v1h-3v1h-2v-1h-2v-1h-2v1l-2-1v1h-4v-1h-2l-2 19h-1l-1 2h-1v1h-1v1h-2v1l-3-1v-1H7v-1H6v-1H5v-1h2v-1h5v-1h1v-1h3v-1h-4v1H5h1l-1 1v-2h1v-2h1v-1h1v-1h2l1-1h3l-1 1 3 1-1 1h1Z" />
  <path id="n5" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m40 111 1-7h2l-1 13h-1v2h-1v1h-1v1h-2v1h-4l1-1h-2v-1l-1-1v-1h-1v-2h-1v-3h1v-2h1v-1h1l1-1h2v-1l3 1v1h2v1Zm3-13-1 6h-2l1-16h2v1h3v1h1v1h6v1h1v1h2v2h1v1h1v1h1v1h-1v1h-4l-1-1h-5v1l-3-1h-2Z" />
  <path id="n4" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="M220 79h2l1 1 1-1v1h2l-1-1h6v1h2v2h1l1 1 1-1v1h1l1 1h-2l1 1h-2v1h-4v-1l-2 1-2 1v1h-6l2 7 1-1 2 13h-1l1 2-1 1v1h-1v1h-2v1l-3 1v-1h-2v-1h-1v-1h-1l-1-2-1 1v-3l1-1-1-2h1v-1h1v-1h2v-1l3-1v1h2v1l1-1-1-6h-2l-3-16 2-1 1 1Z" />
  <path id="n3" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="M226 183v-7h2l-1 13h-1v2h-1v1h-1v1h-2v1h-3v-1l-2-1v-1h-1v-1h-1v-2h-1v-3h1v-2h2v-1h1v-1h2v-1l3 1v1h2v1h1Zm2-14-1 7h-2l1-16h2l3 1v1h1v1h2v-1l4 1v1h1v1h2v2h1v1h1v1h1v1h-2v1h-3l1-1h-2l-3-1v1h-5v-1h-2Z" />
  <path id="n2" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m23 164 2 13h-1v2l-1 1 1 1h-1v1h-2v1l-3 1v-1h-2l-1-1h-1v-1h-1v-2h-1l-1-3h1v-2h1v-1h1v-1l2-1v-1h3v1h2v1l1-1-1-6 2-1Zm-1-15 2-1 1 1h1v1h1v-1l5-1v1h1v1h2v2h1l1 1 1-1v1h1v1h-1v1h-1v1h-2v-1l-2 1v-1l-3 1h1l-2 1-4 1v-1h-2m-2-9h-2l3 17 2-1-3-16Z" />
  <path id="n1" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m188 72 3 13h-1v2h-1v2l-2 1v1h-3v-1l-2 1-1-1h-1v-1h-1v-2l-1 1-1-3 1-1v-2h1l-1-1h1v-1l2-1v-1h3v1l2-1 1 1-1-7h2Zm-2-16h3v1l1-1v1h2l4-1h1l1 1h1l1 2h1v1l1-1 1 1h1v1h-1v1h-1v1h-4l-1-1-2 1-2 1v1h-5l1 7h-2l-3-16h2Z" />
</g>
  <g id="zleft">
    <!-- The left hand and left side of the accordion -->
    <path fill="#676767" d="m11 255-5-1-2 17v19l10 22 9-49-12-8Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m15 257 4-16-9-2-4 16 9 2Z" />
    <path fill="#581610" d="m11 294 7-87h6v-5h6l1-5h6v-5l6 1-16 184h-6v-5l-5-1v-5l-6-1v-5l-5-1 6-65Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m24 283-1 9-19-2 2-35 20 2v8l8 1-1 18-9-1Z" />
    <path fill="#330d09" d="M17 249h7l-5 51h-7l5-51Z" />
    <path fill="#48120d" d="m16 256 7 1-3 36-7-1 3-36Z" />
    <path fill="#6b6108" d="M19 251h3l-1 3-2-1v-2Zm-4 44h3l-1 3h-2v-3Z" />
  </g>
  <g id="zright">
    <!-- The right hand and right side of the accordion -->
    <path fill="#676767" d="M237 253h3l4 18 2 19-11 25-11-51 13-11Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m231 258-6-15 8-3 6 15-8 3Z" />
    <path fill="#581610" d="m224 366-27-176 6-1 1 5h6l1 5 6-1v5l6-1 14 89 8 55-5 2v5l-6 1 1 5-6 1 1 5-6 1Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m221 266-2-8 20-3 5 35-20 2-1-8-9 1-2-17 9-2Z" />
    <path fill="#330d09" d="m228 249-7 1 8 51 7-1-8-51Z" />
    <path fill="#48120d" d="m229 256-7 2 6 36 7-1-6-37Z" />
    <path fill="#6b6108" d="m226 251-3 1 1 3 3-1-1-3Zm7 44-3 1 1 3 3-1-1-3Z" />
  </g>
</svg>

Then, you save the SVG as a file and bring it into the webpage with an HTML img tag.

<img src="zombieaccordion.svg" alt="A zombie playing ear-bleeding notes on an accordion">

Background Images

These animated SVGs don’t just work in the img element, they also work as a CSS background-image. So you can have a hundred little zombies playing the accordion in your background. That said, repeating the animation potentially infinitely takes a hit on page performance. For instance, during testing, when I had the zombie playing as a background image, another copy of the SVG in an img element struggled to animate.

Media Queries

Some media queries from within the SVG pierce the vale and work normally! Queries for width, height and even prefers-color-scheme worked just fine. But it’s a mixed bag. I couldn’t get print, pointer or, worst of all, prefers-reduced-motion to work. But those media queries that do work can give you even more flexibility in how you work with these animated SVGs.

Using @media (max-width: 300px), the animation below only plays when the img is 300 pixels wide or larger. To be clear, the max-width media query is based on the size of the img element, and not the size of the screen.

Plus media queries work even in background images! They can be a little trickier because, for instance, the width queries work on the size the image appears at, not the size of the container.

Gotchas

While most of this works the way any other CSS animation would, there are some limitations to how the CSS works in the SVG file shown in img vs. how it would work embedded in the HTML directly. As replaced content, the SVG is in a sort of sandbox and cannot access much outside the file.

  • The animation has to run automatically. You can’t use hover effects or clicks to start the animation.
  • Despite width and height media queries working within the SVG, viewport units do not work within the SVG.
  • As mentioned above, the animation won’t recognize prefers-reduced-motion, whether the prefers-reduced-motion declaration is within the SVG or in the larger site. While neither would an animated GIF recognize it, it unfortunately won’t give you that additional built-in functionality. On the plus side, any system you had that would prevent an animated GIF from playing should be easily modifiable to also apply to the SVG.
  • The SVG won’t run JavaScript from within the SVG. While a GIF wouldn’t run JavaScript either, I had hoped to get around prefers-reduced-motion not working by implementing it with JavaScript, but that too didn’t work. It’s probably a good thing it doesn’t, though, as that would be a massive security hole.
  • Modern CSS may or may not work. I was delighted to see custom properties and nested selectors working fine in my tests, but exactly what modern features are available and what may not work (like prefers-reduced-motion) will require more testing.

This technique works in all versions of the latest browsers and should theoretically work as far back as style elements and CSS animations are supported in SVG.

Alright let’s get those hamsters… errr… zombies dancing!

]]>
https://frontendmasters.com/blog/replace-your-animated-gifs-with-svgs/feed/ 1 7112
Mingcute https://frontendmasters.com/blog/mingcute/ https://frontendmasters.com/blog/mingcute/#respond Sat, 16 Aug 2025 20:12:18 +0000 https://frontendmasters.com/blog/?p=6782

🔥 Mingcute has been my go-to icon library for a while.- Open source and open license- "Cute" and bubbly icon style with more options than most- Really nice Figma plugin- Iconify support to use in any web project

Ben Holmes (@bholmes.dev) 2025-08-04T12:39:30.195Z
]]>
https://frontendmasters.com/blog/mingcute/feed/ 0 6782
Liquid Glass on the Web https://frontendmasters.com/blog/liquid-glass-on-the-web/ https://frontendmasters.com/blog/liquid-glass-on-the-web/#comments Tue, 29 Jul 2025 02:57:16 +0000 https://frontendmasters.com/blog/?p=6644 Apple is going to version 26 across all its operating systems this fall, and with it, a new design aesthetic they call Liquid Glass. This look is given “automatically” to native app developers using “standard components”. We’ve yet to see Apple’s take on delivering this look through web technology, but many web developers have had a crack already. Let’s take a look at how other’s have brought this design look to the web.

Note that the liquid glass look has been rightfully criticized for text contrast accessibility. This is When you set text over unknown backgrounds, other concessions must be made to ensure text accessibility by way of readability. Digital designers have known this for a long time, so it’s surprising to see it come up as seemingly such a surprise here and see Apple scramble through iterations as new Beta versions roll out. I mention this at the top because it may apply to any of the techniques we’ll look at here. Please be cautious.

This can be a quite complex look to pull off on the web! Yes, we’ve got backdrop-filter and that can be a nice effect that even alone bears some similarity here. But the full effect here is different. There is refracted light at play, edges with highlights, different degrees of frosted effects, and other manipulation of what is behind the glass, simulating what glass might do in real life.

I’d also point you to Geoff Graham’s rundown on all this which is loaded with links.

Glass Displacement

While some liquid glass effects lean on a “frosted” effect (which can be quite helpful for text contract) Jhey Tompkins’ Pen here showcases how there may be no frosted effect at all. The “glass” may be perfectly clear yet still refract the light and distort what you can see behind and through it. The effect here works great as there is no text on top to worry about the readiblity of. There is text behind, which is less important for readability as you can simply move it away.

SVG Blur and Displacement

Here is one that uses backdrop-filter as expected, but it’s not a simple blur as is most commonly seen. It uses the lesser-known ability of backdrop-filter to use url() and link to an SVG filter, in this case using both feDisplacementMap and feGaussianBlur. This makes a very unique light refraction look that is a different type of frosting than just color, all while not losing much color.

A React Component

Max Rovensky got in on this early, producing Liquid Glass React, which provides a component with a bunch of props for controlling the exact look you’re after:

<LiquidGlass
  displacementScale={64}
  blurAmount={0.1}
  saturation={130}
  aberrationIntensity={2}
  elasticity={0.35}
  cornerRadius={100}
  padding="8px 16px"
  onClick={() => console.log('Button clicked!')}
>
  <span className="text-white font-medium">Click Me</span>
</LiquidGlass>

I think all those props showcase nicely just how complex all the “dials” are for liquid glass and how difficult it must be for Apple to turn them all the right amount under the right circumstances to minimize accessibility issues. You’ll face the same should you go down this path with a site!

Atlas Pup Tutorial

The pseudonymous Pup Atlas does a good job of starting with and explaining backdrop-filter before jumping into the other effects needed, ultimately again with SVG filters, to do needed things like distortion.

More Demos!

I like the emphasized highlights in the Pen below, making it extra glass-like. The morphing selected option gives the liquid effect, and the background is blurred so heavily it seems unlikely to cause legibility contrast issues (but you’d still have to be careful about color contrast specifically).

Here’s another example where there is no blur at all, and the frosting is only in the form of a very light transluent white. But distortion is on fully display making it quite glassy. Perhaps on purpose, you can see how the “Artist” part below (“Nao”) becomes hard to read at certain points of the scroll movement.

The light distortion can implemented strongly enough that in the example below you can see some of the refracted wallpaper appear as if it’s moving upward while underneath the navbar elements as wallpaper otherwise is moving downward.

When we talk about “frosted” glass typically it just means extra blurring and perhaps a light color overlay, but here the frosting is actually textured which is relatively common in real life glass usage, like office doors and shower sliders.

These water droplets below are made with largely the same techniques as we’ve seen so far, but really dialed up to 11 with loads of box-shadow both in and out. Usually in Liquid Glass, the shadows aren’t particularly as heavy, but of course, it looks great here. It makes me think if this visual language is really more about the liquid than the glass.

]]>
https://frontendmasters.com/blog/liquid-glass-on-the-web/feed/ 2 6644
Step Gradients with a Given Number of Steps https://frontendmasters.com/blog/step-gradients-with-a-given-number-of-steps/ https://frontendmasters.com/blog/step-gradients-with-a-given-number-of-steps/#respond Mon, 30 Jun 2025 17:30:02 +0000 https://frontendmasters.com/blog/?p=6338 Let’s say we want some stepped gradients like the ones below, with a certain number of steps.

Desired_linear_result screenshot. Shows a bunch of linear gradients in 10 steps from left to right.
the desired result

Before reading further, try thinking about this. You are only given the start and end steps and the rest should be obtained via linear interpolation. How would you create them? Maybe put together a quick demo.

Note that this is a different problem from stepped gradients with a certain step size. The given step size problem is way more complex and it’s impossible to solve with CSS alone as long as we don’t have native looping. SVG filters provide a solution, though they limit us to using just pixel values for the step size; having em or calc() values there isn’t possible without JS.

The Classic CSS Approach

This means computing the intermediate step values and specifying a stop and positions for each step. Something like this:

.step {
  background: linear-gradient(
    90deg,
    #00272b 10%,
    #193f2f 0 20%,
    #325833 0 30%,
    #4b6f36 0 40%,
    #64873b 0 50%,
    #7c9f3f 0 60%,
    #95b743 0 70%,
    #aecf47 0 80%,
    #c7e74b 0 90%,
    #e0ff4f 0
  );
}

Tedious!

And this is the simplified version, the one that avoids repeating stops and stop positions, something that has been well-supported cross-browser for over half a decade, by the way. Because I often see the ancient, inefficient version that duplicates everything:

.step {
  background: linear-gradient(
    90deg,
    #00272b 0%,  #00272b 10%,
    #193f2f 10%, #193f2f 20%,
    #325833 20%, #325833 30%,
    #4b6f36 30%, #4b6f36 40%,
    #64873b 40%, #64873b 50%,
    #7c9f3f 50%, #7c9f3f 60%,
    #95b743 60%, #95b743 70%,
    #aecf47 70%, #aecf47 80%,
    #c7e74b 80%, #c7e74b 90%,
    #e0ff4f 90%, #e0ff4f 100%
  );
}

We could generate the stop list using Sass with looping and the mix() function.

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
@function stop-list($c0, $c1, $n) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l, mix($c1, $c0, $i * 100%/ ($n - 1)) 0 ($i + 1) * 100% / $n;
  }

  @return $l;
}

.step {
  background: linear-gradient(90deg, stop-list(#00272b, #e0ff4f, 10));
}

This produces the following compiled result:

.step {
  background: linear-gradient(
    90deg,
    #00272b 0 10%,
    #193f2f 0 20%,
    #325833 0 30%,
    #4b6f36 0 40%,
    #64873b 0 50%,
    #7c9f3f 0 60%,
    #95b743 0 70%,
    #aecf47 0 80%,
    #c7e74b 0 90%,
    #e0ff4f 0 100%
  );
}

Not bad — but we should probably tweak that generating function a bit so we get rid of the unnecessary 0 and 100% stop positions at the very start and at the very end and to add rounding in case 100 is not a multiple of $n.

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
@function stop-list($c0, $c1, $n) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      mix($c1, $c0, $i * 100%/ ($n - 1))
      // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
      // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if($i < $n, round(($i + 1) * 100% / $n), unquote(""));
  }

  @return $l;
}

Much better — but the Sass function doesn’t look pretty and it gets even more complex if we need to round those percentages to a certain precision $p, a certain number of decimals, not just to integer percentage values:

@use "sass:math";

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
// $p: rounding precision, how many decimals to keep
@function stop-list($c0, $c1, $n, $p: 2) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      mix($c1, $c0, $i * 100%/ ($n - 1))
      // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
      // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if(
        $i < $n - 1,
        round(($i + 1) * 100% / $n * math.pow(10, $p)) * math.pow(10, -$p),
        unquote("")
      );
  }

  @return $l;
}

We still have that long list of values in the compiled CSS and, if we have 7 elements like this with stepped gradients, each is going to get its own long list.

Another problem with this is we cannot tweak the stepped gradient from DevTools. Not in a non-tedious way that doesn’t involve changing almost every step manually. If we want to change one of the end steps from DevTools, we have to also change all the Sass-computed intermediate steps.

Screenshot showing the Sass code that generates the steps and the visual result. On the right, a DevTools panel is open and we're modifying the first step in the compiled code the browser can see, but this doesn't change the Sass-generated intermediate ones.
what happens when we change one end step from DevTools

Using CSS variables can get around both these problems, but we cannot use CSS variable values inside the Sass mix() function. In order to use CSS variables, we have to use CSS color-mix() function:

@use "sass:math";

// $n: number to round to a certain precision
// $p: rounding precision, how many decimals to keep
@function round-to($n, $p: 2) {
  @return round($n * math.pow(10, $p)) * math.pow(10, -$p);
}

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
// $p: rounding precision, how many decimals to keep
@function stop-list($c0, $c1, $n, $p: 2) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      color-mix(in srgb, $c1 round-to($i * 100%/ ($n - 1), $p), $c0)
        // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
        // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if($i < $n - 1, round-to(($i + 1) * 100% / $n, $p), unquote(""));
  }

  @return $l;
}

Which produces the following ugly compiled CSS:

.step {
  background: linear-gradient(
    90deg,
    color-mix(in srgb, var(--c1) 0%, var(--c0)) 10%,
    color-mix(in srgb, var(--c1) 11.11%, var(--c0)) 0 20%,
    color-mix(in srgb, var(--c1) 22.22%, var(--c0)) 0 30%,
    color-mix(in srgb, var(--c1) 33.33%, var(--c0)) 0 40%,
    color-mix(in srgb, var(--c1) 44.44%, var(--c0)) 0 50%,
    color-mix(in srgb, var(--c1) 55.56%, var(--c0)) 0 60%,
    color-mix(in srgb, var(--c1) 66.67%, var(--c0)) 0 70%,
    color-mix(in srgb, var(--c1) 77.78%, var(--c0)) 0 80%,
    color-mix(in srgb, var(--c1) 88.89%, var(--c0)) 0 90%,
    color-mix(in srgb, var(--c1) 100%, var(--c0)) 0
  );
}

We can tweak the Sass to get rid of the first and last color-mix() and use the given ends --c0 and --c1 instead (live demo):

@use "sass:math";

// $n: number to round to a certain precision
// $p: rounding precision, how many decimals to keep
@function round-to($n, $p: 2) {
  @return round($n * math.pow(10, $p)) * math.pow(10, -$p);
}

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
// $p: rounding precision, how many decimals to keep
@function stop-list($c0, $c1, $n, $p: 2) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      if(
          $i > 0,
          if(
            $i < $n - 1,
            color-mix(in srgb, $c1 round-to($i * 100%/ ($n - 1), $p), $c0),
            $c1
          ),
          $c0
        )
      // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
      // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if($i < $n - 1, round-to(($i + 1) * 100% / $n, $p), unquote(""));
  }

  @return $l;
}

But the generated CSS still looks ugly and difficult to read:

.step {
  background: linear-gradient(
    90deg,
    var(--c0) 10%,
    color-mix(in srgb, var(--c1) 11.11%, var(--c0)) 0 20%,
    color-mix(in srgb, var(--c1) 22.22%, var(--c0)) 0 30%,
    color-mix(in srgb, var(--c1) 33.33%, var(--c0)) 0 40%,
    color-mix(in srgb, var(--c1) 44.44%, var(--c0)) 0 50%,
    color-mix(in srgb, var(--c1) 55.56%, var(--c0)) 0 60%,
    color-mix(in srgb, var(--c1) 66.67%, var(--c0)) 0 70%,
    color-mix(in srgb, var(--c1) 77.78%, var(--c0)) 0 80%,
    color-mix(in srgb, var(--c1) 88.89%, var(--c0)) 0 90%,
    var(--c1) 0
  );
}

So… isn’t there another way?

The No-Support Ideas

The spec defines a stripes() image function and my first thought was it should allow us to do this, though it was not clear to me in which direction the stripes would go, if we have a way of specifying that:

.step {
  background: stripes(
    #00272b,
    #193f2f,
    #325833,
    #4b6f36,
    #64873b,
    #7c9f3f,
    #95b743,
    #aecf47,
    #c7e74b,
    #e0ff4f
  );
}

But the more I read the first paragraph in the spec definition, the more it sounds like this wasn’t meant for backgrounds, but for stripes going along the direction of things like borders (including roundings) and text strokes. In this case, the line direction would be the “missing” direction.

There’s also a proposal to add animation-like gradient easing, including a steps() function, though, just like in the case of the stripes() function, there’s a lot about this I don’t understand and the proposal doesn’t seem to have moved much lately.

Since neither of these can be used today, what other solutions that we can currently use do we have?

Enter the SVG filter Enhancement

Wait, don’t run away screaming! I promise that, leaving aside small browser issues, the technique is actually simple and even has better support than the CSS gradient double stop position syntax!

Let’s say we have a plain black to red gradient on an element that also gets an SVG filter:

.step {
  background: linear-gradient(90deg, #000, #f00);
  filter: url(#step)
}

We’ve picked this gradient in particular because it’s a gradient from 0% to 100% one one of the RGBA channels (in this case, the red channel R) while all other channel values stay constant. It could also be written as:

.step {
  background: linear-gradient(90deg, rgb(0% 0% 0% / 1), rgb(100% 0% 0% / 1));
  filter: url(#step)
}

Writing it like this makes it even more obvious that the green and blue channels (the second and third values in the rgb()) are zeroed everywhere all the time (before applying the filter and after too, as the SVG filter, which we’ll see in a second, doesn’t affect any channel other than the red one), while the alpha (the final value, the one after the slash in the rgb()) is maxed out everywhere all the time.

So basically, we go from a 0% red (which is equivalent to black) at the start on the left to a 100% red (which is the same as the red keyword value) at the end on the right.

a simple left to right black to red gradient band
our initial gradient

This SVG filter needs to live inside an svg element. Since this svg element only exists to contain the filter, we don’t have any graphics that are going to be visible on the screen within it, it is functionally the same as a style element. So we zero its dimensions (width and height attributes), hide it from screen readers (aria-hidden) and take it out of the document flow (from the CSS).

<svg width='0' height='0' aria-hidden='true'>
  <filter id='step' color-interpolation-filters='sRGB'>
  </filter>
</svg>
svg[height='0'][aria-hidden='true'] { position: fixed }

The filter element also gets another attribute other than the id. We aren’t going into it, just know we need to set it to sRGB for cross-browser compatibility if we mess with the RGB channels, as the spec default and the one used by all browsers nowadays is linearRGB, but sRGB is likely what we want in most cases, plus it used to be the only value that worked in Safari, though that has recently changed.

In our particular case, if we don’t set the color-interpolation-filters attribute to sRGB, we won’t get equally sized steps in any browser other than older Safari versions which use sRGB anyway.

Inside this filter, we have a feComponentTransfer primitive. This allows us to manipulate the RGBA channels individually (via the suggestively named feFuncR, feFuncG, feFuncB and feFuncA) in various ways. In this case, we have a gradient from black (0% red) to red (100% red) so we manipulate the red channel using feFuncR.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='step' color-interpolation-filters='sRGB'>
    <feComponentTransfer>
      <feFuncR type='discrete' tableValues='0 1'/>
    </feComponentTransfer>
  </filter>
</svg>

We’ve set the type of feFuncR to discrete, meaning the output red channel only has discrete values and these values are those specified by the tableValues attribute, so in our case here, 0 and 1 (1 being the decimal representation of 100%).

What does this mean? Where the input value for the red channel is below 50% (.5), so on the left half of the initial gradient, the filter output value for the red channel is 0 (zeroed). And where the input value for the red channel is at least 50% (.5), so on the right half of the initial gradient, the filter output for the red channel is 1 (maxed out). Since the green and blue channels are zero everyehere, this makes the left half of our gradient a black step and the right half a red step.

Screenshot of the two step gradient example. Shows the filter input (the initial left to right, black to red gradient) and output (the filtered gradient, the one in two steps: black and red). On top of these gradients, we also have the boundary lines for the two intervals the [0%, 100%] interval of the red channel progression (which coincides with the input gradient) is split into. At the bottom, the feFuncA primitive creating this result is also shown.
a black to red stepped gradient with 2 steps, before and after applying the step filter

Basically, when we have two values for tableValues, the [0, 1] interval of possible red input values gets split into two: [0, .5) and [.5, 1]. The first interval [0, .5) gets mapped to the first of the two tableValues and the second interval [.5 1] gets mapped to the second of the two tableValues.

Now let’s say we add a .5 value in between:

<feFuncR type='discrete' tableValues='0 .5 1'/>

This gives us three steps from left to right: a 0% red (black), a 50% red (maroon) and a 100% red (red).

Now that we have three values for tableValues, the [0, 1] interval of possible red input values gets split into three: [0, .(3)), [.(3),.(6)) and [.(6), 1]. The first one, in our case being the left third of the gradient, gets mapped to 0, so we have 0% red (black) there. The second one, in our case being the middle third of the gradient, gets mapped to .5, so we have 50% red (marron) there. The third one, in our case being the right third of the gradient, gets mapped to 1, so we have 100% red (red) there.

Screenshot of the three step gradient example. Shows the filter input (the initial left to right, black to red gradient) and output (the filtered gradient, the one in three steps: black, maroon and red). On top of these gradients, we also have the boundary lines for the three intervals the [0%, 100%] interval of the red channel progression (which coincides with the input gradient) is split into. At the bottom, the feFuncA primitive creating this result is also shown.
a black to red stepped gradient with 3 steps, before and after applying the step filter

We can also have four equally spaced values for tableValues:

<feFuncR type='discrete' tableValues='0 .333 .667 1'/>

This gives us 4 steps from left to right:

Screenshot of the four step gradient example. Shows the filter input (the initial left to right, black to red gradient) and output (the filtered gradient, the one in four steps). On top of these gradients, we also have the boundary lines for the four intervals the [0%, 100%] interval of the red channel progression (which coincides with the input gradient) is split into. At the bottom, the feFuncA primitive creating this result is also shown.
a black to red stepped gradient with 4 steps, before and after applying the step filter

In general, n equally spaced values for tableValues produce n equal steps for our black to red gradient:

adjusting the number of steps adjusts tableValues and the visual result (live demo – note that if you’re on a wide gamut display, you’re likely to see this broken in Chrome)

If we use Pug, we can easily generate these values within a loop:

- let a = new Array(n).fill(0).map((_, i) => +(i/(n - 1)).toFixed(2));

svg(width='0' height='0' aria-hidden='true')
filter#step(color-interpolation-filters='sRGB')
feComponentTransfer
feFuncR(type='discrete' tableValues=`${a.join(' ')}`)

Great, but this is a simple black to red gradient. How can we create a stepped orange to purple gradient, for example?

Extending the Technique: Different Palettes

This technique works for gradients where we vary any of the four RGBA channels from 0% to 100% along the gradient line while keeping all other three channels constant along the entire gradient line, though not necessarily zeroed or maxed out like in the black to red gradient example.

For example, we could make the green channel G go from 0% to 100% along the gradient line, while the red channel is fixed at 26% for the entire gradient, the blue channel is fixed at 91% for the entire gradient and the alpha channel is fixed at 83% for the entire gradient. This means we go from a slightly faded blue first step (rgb(26% 0% 91%/ 83%)) to a somewhat desaturated aqua (rgb(26% 100% 91%/ 83%)) for the final step.

Screenshot of a five step gradient example where we vary the green channel while keeping all others constant along the entire gradient line. Shows the filter input (the initial left to right, faded blue to desaturated aqua gradient) and output (the filtered gradientin five steps). On top of these gradients, we also have the boundary lines for the five intervals the [0%, 100%] interval of the green channel progression (which coincides with the input gradient progression) is split into.
our example gradient, initial and with a 5 step filter applied

Below you can see how you can play with an interactive demo that allows to create a custom 5 step gradient that has steps along one of the four channels while the others have a fixed value, custom set by us, but fixed for the entire gradient.

picking the gradient channel and adjusting values for the fixed ones (live demo – note that this demo may also suffer from the Chrome wide gamut bug)

Out of all these cases, the most interesting one is the one varying the alpha.

First off, using the alpha channel allows us to avoid both the wide gamut bug and another Chrome bug we hit when using one of the RGB channels, but not the alpha channel.

Chrome screenshot of four 5-step gradients obtained by varying each of the four RGBA channels while keeping all other constant. For those varying the RGB channels, the step edges are jagged, which doesn't happen when varying the alpha channel.
the step edges are jagged in Chrome for the steps created on the RGB channels, but not on the alpha channel (live test)

Secondly, what this allows us to do is to fade any RGB value in steps along the entire alpha interval. And if we place this stepped fade on top of another solid RGB background, we get our desired stepped gradient where we only need to know the start (the layer underneath, seen exactly as set for the first step where the alpha of the stepped gradient above is 0) and end step (used for the stepped gradient on top).

This is exactly how the gradients in the image at the start of the article were created. We have a .step element with a solid background set to the start step --c0 and a pseudo fully covering it with a gradient where we vary the alpha of the end step --c1.

.step {
  position: relative;
  background: var(--c0);

  &::before {
    position: absolute;
    inset: 0;
    background: linear-gradient(90deg, #0000, var(--c1));
    filter: url(#step);
    content: "";
  }
}

This pseudo has the alpha step filter applied.

//- change this to change number of steps
- let n = 10;
- let a = new Array(n).fill(0).map((_, i) => +(i/(n - 1)).toFixed(3));

svg(width='0' height='0' aria-hidden='true')
filter#step
feComponentTransfer
feFuncA(type='discrete' tableValues=a.join(' '))

Note that in this case when we’re creating the steps on the alpha channel and we’re not touching the RGB channels, we don’t even need the color-interpolation-filters attribute anymore.

You can check out the live demo for various --c0 and --c1 combinations below:

And yes, in case anyone is wondering, the pure CSS and the SVG filter results are identical – you can check it out in this demo.

Simplifying the Technique for the Future

It feels a bit inconvenient to use pseudo-elements for this instead of just having a background. The filter() function solves this problem. It takes an image (which can also be a CSS gradient) and a filter chain as inputs, then outputs the filtered image. This output can be used anywhere an image can be used in CSS — as a background-image, mask-image, border-image, even shape-outside!

This way, our CSS can become:

.step {
  background: 
    filter(linear-gradient(90deg, #0000, var(--c1)), url(#step)) 
    var(--c0)
}

Much simpler!

The catch? While Safari has supported this for a decade (I first learned about this function and the Safari implementation in the summer of 2015!), no other browser has followed since. Here are the Chrome and Firefox bugs for anyone who wants to show interest and add to the use cases.

Here is the filter() version of the stepped gradients demo, but keep in mind it only works in Safari.

Extending the Technique: Back and Forth Steps

Now let’s say we wanted to modify our gradient to go back to black from red in the middle (we’re using the red and black gradient example here because of the contrast):

.step {
  background: linear-gradient(90deg, #000, #f00, #000);
  filter: url(#step)
}

The filter is generated exactly the same as before:

- let n = 5;
- let a = new Array(n).fill(0).map((_, i) => +(i/(n - 1)).toFixed(3));

svg(width='0' height='0' aria-hidden='true')
filter#step(color-interpolation-filters='sRGB')
feComponentTransfer
feFuncR(type='discrete' tableValues=`${a.join(' ')}`)

This Pug is producing the following HTML:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='step' color-interpolation-filters='sRGB'>
    <feComponentTransfer>
      <feFuncR id='func' type='discrete' tableValues='0 .25 .5 .75 1'/>
    </feComponentTransfer>
  </filter>
</svg>

In this case of a gradient going back, basically being reflected with respect to the middle, the middle red step is doubled when using the exact same red channel step gradient as before.

Screenshot of a 2·5 = 10 step gradient. Shows the filter input (the initial left to right, black to red and then back to black gradient) and output (the filtered gradient, in 5 + 5 steps). On top of these gradients, we also have the boundary lines for the intervals the [0%, 100%] interval of the red channel progression (which coincides with the first half of the input gradient) is split into. This simple linear gradient reflection results in a doubling of the middle red step.

This makes perfect sense. We’re reflecting the gradient and this repeats the 100% red step in the middle. We don’t have n steps across the gradient line anymore, we have 2·n of them, with the two in the middle having the same RGBA value (100% red).

What we need to do is make it look like we only have 2·n - 1 steps by making the two steps in the middle half the size of the other ones. This means moving the cutoff to 100% red ((n - 1)/n·100% red, which is 80% red in the n = 5 example case here) at half a 100%/(2·n - 1) interval from the middle, both before and after. So our CSS becomes:

.step {
  /* cutoff to 100% red */
  --r: rgb(calc((var(--n) - 1) / var(--n) * 100%), 0%, 0%);
  /* distance from the middle of gradient line */
  --d: 0.5 * 100%/ (2 * var(--n) - 1);
  background: linear-gradient(
    90deg,
    #000,
    var(--r) calc(50% - var(--d)),
    #f00,
    var(--r) calc(50% + var(--d)),
    #000
  );
}

This does the trick!

Screenshot of a 2·5 = 10 step gradient. Shows the filter input (the initial left to right, black to red and then back to black gradient) and output (the filtered gradient, in 5 + 5 steps). On top of these gradients, we also have the boundary lines for the intervals the [0%, 100%] interval of the red channel progression (which coincides with the first half of the input gradient) is split into. The trick here is that the two middle intervals aren't equal to all the others, but half of them. This way, the middle red step is still doubled, but it's also half the size of all other steps, so it doesn't look like it's repeated twice.

The stops we’re specifying in the CSS in the particular case of n = 5 are the 0% red (implicitly at the 0% point of the gradient line), the 80% red at the 44.(4)% point of the gradient line (set explicitly), the 100% red (implicitly at the 50% point of the gradient line), the 80% red at the 55.(5)% point of the gradient line (also set explicitly) and the 0% red (implicitly at the 100% point of the gradient line).

If we wanted to fine tune things, we could also simplify the middle offset computations:

50% - d = 
50% - .5·100%/(2·n - 1) =
50% - 50%/(2·n -1) =
50%·(1 - 1/(2·n - 1)) =
50%·(1 - f)

So our CSS would become:

.step {
  /* cutoff to 100% red */
  --r: rgb(calc((var(--n) - 1) / var(--n) * 100%), 0%, 0%);
  /* fraction of distance from middle of gradient line */
  --f: 1/ (2 * var(--n) - 1);
  background: linear-gradient(
    90deg,
    #000,
    var(--r) calc(50% * (1 - var(--f))),
    #f00,
    var(--r) calc(50% * (1 + var(--f))),
    #000
  );
}

Note that the SVG filter remains the exact same as before, we just pass the number of steps n to the CSS as a custom property:

- let n = 5;

// exact same SVG filter as before
.step(style=`--n: ${n}`)

If we want our gradient to repeat and we don’t want a doubled end/start step, we need to do something similar at the other end of the red scale (channel scale in general) and make it look as if the start/end step is 100%/(2·(n - 1)) of the gradient line (not 100%/(2·n - 1) like in the case of no gradient repetition reflection).

.step {
  /* cutoff to 0% red */
  --r0: rgb(calc(1 / var(--n) * 100%), 0%, 0%);
  /* cutoff to 100% red */
  --r1: rgb(calc((var(--n) - 1) / var(--n) * 100%), 0%, 0%);
  /* fraction of distance from middle/ end of gradient line */
  --f: 1/ (2 * (var(--n) - 1));
  background: linear-gradient(
      90deg,
      #000,
      var(--r0) calc(50% * var(--f)),
      var(--r1) calc(50% * (1 - var(--f))),
      #f00,
      var(--r1) calc(50% * (1 + var(--f))),
      var(--r0) calc(50% * (2 - var(--f))),
      #000
    )
    0/ 50%;
}

Note that we’ve used a background-size of 50%, which means 2 repetitions. For a generic number of repetitions q, our background-size is 100%/q.

For the alpha channel variation that allows us to get any gradient from any --c0 to any --c1, it’s very similar:

.step {
  /* cutoff to 0% alpha */
  --a0: rgb(from var(--c1) r g b/ calc(1 / var(--n)));
  /* cutoff to 100% alpha */
  --a1: rgb(from var(--c1) r g b/ calc((var(--n) - 1) / var(--n)));
  /* fraction of distance from middle/ end of gradient line */
  --f: 1/ (2 * (var(--n) - 1));
  position: relative;
  background: var(--c0);

  &::before {
    position: absolute;
    inset: 0;
    background: linear-gradient(
        90deg,
        #0000,
        var(--a0) calc(50% * var(--f)),
        var(--a1) calc(50% * (1 - var(--f))),
        var(--c1),
        var(--a1) calc(50% * (1 + var(--f))),
        var(--a0) calc(50% * (2 - var(--f))),
        #0000
      )
      0 / calc(100% / var(--q));
    filter: url(#step);
    content: "";
  }
}

You can play with the demo below by changing the number of repetitions q to see how the result changes without needing to modify anything else.

What if we wanted to have full steps at the start of the first repetition and at the end of last repetition? Well, in that case, given a number q of repetitions, we can compute the width of the lateral borders to be equal to half a step size on each side. A step size is 1/(2·q·(n - 1) + 1) of the pseudo parent’s content-box width, so the border-width on the pseudo needs to be half of that.

.step {
  /* cutoff to 0% alpha */
  --a0: rgb(from var(--c1) r g b/ calc(1 / var(--n)));
  /* cutoff to 100% alpha */
  --a1: rgb(from var(--c1) r g b/ calc((var(--n) - 1) / var(--n)));
  /* fraction of distance from middle/ end of gradient line */
  --f: 1/ (2 * (var(--n) - 1));
  container-type: inline-size;
  position: relative;
  background: var(--c0);

  &::before {
    position: absolute;
    inset: 0;
    border: solid 0 #0000;
    border-width: 0 calc(50cqw / (2 * var(--q) * (var(--n) - 1) + 1));
    background: linear-gradient(
        90deg,
        #0000,
        var(--a0) calc(50% * var(--f)),
        var(--a1) calc(50% * (1 - var(--f))),
        var(--c1),
        var(--a1) calc(50% * (1 + var(--f))),
        var(--a0) calc(50% * (2 - var(--f))),
        #0000
      )
      0 / calc(100% / var(--q));
    filter: url(#step);
    content: "";
  }
}

Modified interactive demo:

What makes this possible and easy is the fact that, by default, background-size and background-position are relative to the padding-box (their position is relative to the top left corner of the padding-box, so that 0 position is relative to the padding-box left edge and their size is relative to the padding-box dimensions, so that 100% in calc(100%/var(--q)) is relative to the padding-box width), but extra background repetitions are painted in all directions under the border too.

The box model and the default way backgrounds are painted. Their size and position is relative to the padding-box, but repetitions are also painted underneath the borders.
by default, backgrounds cover the entire border-box, but start from the top left corner of the padding-box

Note that the whole reflect and repeat could be very much simplified on the CSS side if CSS gradients also allowed reflecting repetition like SVG ones do.

Extending the Technique: Different Gradients

We’ve only used a left to right linear-gradient() so far, but the direction of the gradient may vary and we may well use a radial-gradient() or a conic-gradient() instead. Nothing changes about the filter in this case. The gradients below all use the exact same filter.

Regardless of the gradient type, the filter() function is going to simplify things if Chrome and Firefox implement it too. The relevant code for the demo above would become:

.step {
  --c0: #00272b;
  --c1: #e0ff4f;
  --s: #0000, var(--c1);
  background: filter(var(--img, conic-gradient(var(--s))), url(#step)) var(--c0);
}

.linear {
  --img: linear-gradient(to right bottom, var(--s));
}
.radial {
  --img: radial-gradient(circle, var(--s));
}

You can check out the live demo, but remember it only works in Safari.

Refining Things

The results aren’t perfect. When using radial or conic gradients or even linear ones at weird angles, we get jagged edges in between the steps. I guess it doesn’t look that bad in between steps that may be reasonably similar, but if we wanted to do something about it, what could we do?

When creating the steps from the CSS, we can always use the 1px difference trick (avoid using a 1% difference for this, it can be unreliable) to smoothen things for radial and linear ones (the conic gradient case is a lot more complicated though and I haven’t been able to find a pure CSS solution that doesn’t involve emulating the conic gradient with a linear one).

But what can we do about it in the case of steps created via an SVG filter?

Given the introduction of the 1px difference produces an effect similar to a tiny blur, the first instinct would be to try to blur the whole thing. However, the result looks terrible, even when we correct the edge alpha decrease, so the blur idea goes out the window!

We could also smoothen the edges of each step using a technique similar to the no matrix filter gooey effect. That mostly works, save for a bit of weird rounding at the edges for all gradients and in the middle of the conic one. But that’s a lot of filter primitives, a lot for such a tiny visual gain.

Another option would be to try to simplify this technique and smoothen the edges of even steps – this avoids increasing the number of primitives with the number of steps, but also comes with other technical challenges. So at the end of the day, it’s another path I’m not fully convinced it’s worth taking for such a small visual gain. Not to mention the weird edge rounding and the even more obvious clump in the middle of the conic-gradient().

Finally, we could make the gradients grainy. But the approach discussed in a previous article is likely not what we’re going for.

There may be cases where it is what we want, for example when it comes to such dithered band cards:

Most of the time, this is probably not the desired result. So maybe try another approach to grainy gradients, one that doesn’t use displacement maps and also doesn’t alter the gradient palette?

We could use the old approach of layering and blending with a desaturated noise layer whose alpha we also reduce to a certain extent before blending:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' x='0' y='0' width='1' height='1' 
          color-interpolation-filters='sRGB'>
    <feTurbulence type='fractalNoise' baseFrequency='.713' numOctaves='3'/>
    <feColorMatrix type='saturate' values='0'/>
    <feComponentTransfer>
      <feFuncA type='linear' slope='.6'/>
    </feComponentTransfer>
    <feBlend in2='SourceGraphic' mode='overlay'/>
  </filter>
</svg>

Here, we fully desaturate the noise produced by feTurbulence and then scale down its alpha (to .6 of what it would be otherwise).

This is the path taken by the sunburst demo below, which was created taking inspiration from the heading image of an earlier post here:

This comes with the disadvantage of altering the original palette, but if that’s not as much of an issue, it could work.

Finally, another option would be XOR-ing the alpha of the desaturated and reduced alpha noise layer and the alpha of the steps:

]]>
https://frontendmasters.com/blog/step-gradients-with-a-given-number-of-steps/feed/ 0 6338
Grainy Gradients https://frontendmasters.com/blog/grainy-gradients/ https://frontendmasters.com/blog/grainy-gradients/#comments Fri, 13 Jun 2025 14:37:36 +0000 https://frontendmasters.com/blog/?p=6066 You know when you set a background gradient or a gradient mask and you get an ugly banding effect? If you can’t picture what I mean, here’s an example:

A left to right pinkinsh orange to dark grey gradient exhibiting banding.
 example gradient with banding

Previous Solutions

Over time, I’ve seen a couple of approaches commonly recommended for solving this. The first is to simply introduce more stops (gradient “easing”), which I’m not really keen on doing, even if I can just generate them in a Sass loop and never need to know about them. The second one is to make the gradient noisy. Let’s do that.

The way I first went about making gradients grainy was to have a gradient layer and a noise layer (using pseudo-elements for the layers) and then blend them together. I first did this in response to a question asked on X. That video became one of my most watched ones ever, which isn’t something I’m happy about anymore because I’ve come to find that technique to be overly complicated, like scratching behind the right ear with the left foot.

A few months later, I saw an article that was doing something similar: placing a gradient layer and a noise layer one on top of the other. Unlike my approach, it wasn’t blending the two layers and instead was relying on one end of the gradient being transparent to allow the noise to show through. For the other end to be something other than transparent, it would layer an overlay and blend it. Just like my layered pseudos approach… too complicated! Not to mention that the contrast() and brightness() tampering (meant to highlight the grain) make this only work for certain gradient inputs and they greatly alter the saturation and luminosity of the original gradient palette.

In time, I would improve upon my initial idea and, almost half a decade later, I would make a second video on the topic, presenting a much simplified technique. Basically, the gradient would get fed into an SVG filter, which would generate a noise layer, desaturate it and then place it on top of the input gradient. No external files, no base64-ing anything, no separate (pseudo)element layers for the noise and the gradient.

Still, it didn’t take long before I wasn’t happy with this solution anymore, either.

The big problem with layering the noise and the gradient

The problem with all of these solutions so far is that they’re changing the gradient. Depending on the particular technique we use, we always end up with a gradient that’s either darker, brighter, or more saturated than our original one.

We can reduce the noise opacity, but in doing so, our gradient becomes less grainy and the efficiency of fixing banding this way decreases.

A better solution

How about not layering the the noise layer and instead using it as a displacement map?

What this does is use two of the four RGBA channels of the noise layer to determine how the individual pixels of the input gradient are shifted along the x and y axes.

Both the filter input (our gradient) and the noise layer can be taken to be 2D grids of pixels. Each pixel of our input gradient gets displaced based on the two selected channel values of its corresponding pixel in the noise layer (used as a displacement map).

A channel value below 50% means moving in the positive direction of the axis, a channel value above 50% means moving in the negative direction of the axis and a channel value of exactly 50% means not moving at all.

The displacement formula for a generic channel value of C and a displacement scale of S is the following:

(.5 - C)*S

If we use the red channel R for displacement along the x axis and the alpha channel A for displacement along the y axis, then we have:

dx = (.5 - R)*S
dy = (.5 - A)*S

Note that the values for both R and A are in the [0, 1] interval (meaning channel values are zeroed at 0 and maxed out at 1), so the difference between the parenthesis is in the [-.5, .5] interval.

The bigger the scale value S is, the more the gradient pixels mix along the gradient axis depending on the red R and alpha A channel values of the displacement map generated by feTurbulence.

Let’s see our code!

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713' numOctaves='4'/>
    <feDisplacementMap in='SourceGraphic' scale='150' xChannelSelector='R'/>
  </filter>
</svg>

Since the <svg> element is only used to hold our filter (and the only thing a filter does is apply a graphical effect on an already existing element), it is functionally the same as a <style> element, so we zero its dimensions and hide it from screen readers using aria-hidden. And, in the CSS, we also take it out of the document flow (via absolute or fixed positioning) so it doesn’t affect our layout in any way (which could happen otherwise, even if its dimensions are zeroed).

svg[height='0'][aria-hidden='true'] { position: fixed }

The <filter> element also has a second attribute beside its id. We aren’t going into it here because I don’t really understand it myself. Just know that, in order to get our desired result cross-browser, we always need to set this attribute to sRGB whenever we’re doing anything with the RGB channels in the filter. The sRGB value isn’t the default one (linearRGB is), but it’s the one we likely want most of the time and the only one that works properly cross-browser.

The feTurbulence primitive creates a fine-grained noise layer. Again, we aren’t going into how this works in the back because I haven’t been able to really understand any of the explanations I’ve found or I’ve been recommended for the life of me.

Just know that the baseFrequency values (which you can think of as being the number of waves per pixel) need to be positive, that integer values produce just blank and that bigger values mean a finer grained noise. And that numOctaves values above the default 1 allow us to get a better-looking noise without having to layer the results of multiple feTurbulence primitives with different baseFrequency values. In practice, I pretty much never use numOctaves values bigger than 3 or at most 4 as I find above that, the visual gain really can’t justify the performance cost.

We also switch here from the default type of turbulence to fractalNoise, which is what’s suited for creating a noise layer.

This noise is then used as a displacement map (the second input, in2, which is by default the result of the previous primitive, feTurbulence here, so we don’t need to set it explicitly) for the filter input (SourceGraphic). We use a scale value of 150, which means that the maximum an input pixel can be displaced by in either direction of the x or y axis is half of that (75px) in the event the channel used for x or y axis displacement is either zeroed (0) or maxed out (1) there. The channel used for the y axis displacement is the default alpha A, so we don’t need to set it explicitly, we only set it for the x axis displacement.

We’re using absolute pixel displacement here, as relative displacement (which requires the primitiveUnits attribute to be set to objectBoundingBox on the <filter> element) is not explicitly defined in the spec, so Chrome, Firefox and Safari each implement it in a different way from the other two for non-square filter inputs. I wish that could be a joke, but it’s not. This is why nobody really uses SVG filters much — a lot about them just doesn’t work. Not consistently across browsers anyway.

At this point, our result looks like this:

Grainy gradient with dithered edges. A bright pink outline shows us the boundary of the filter input. Within this boundary, we have transparent pixels. Outside it, we have opaque pixels.

Not quite what we want. The dashed bright pink line shows us where the boundary of the filter input gradient box was. Along the edges, we have both transparent pixels inside the initial gradient box and opaque pixels outside the initial gradient box. Two different problems, each needing to get fixed in a different way.

To cover up the transparent pixels inside the initial gradient box, we layer the initial gradient underneath the one scrambled by feDisplacementMap. We do this using feBlend with the default mode of normal (so we don’t need to set it explicitly), which means no blending, just put one layer on top of the other. The bottom layer is specified by the second input (in2) and in our case, we want it to be the SourceGraphic. The top layer is specified by the first input (in) and we don’t need to set it explicitly because, by default, it’s the result of the previous primitive (feDisplacementMap here), which is exactly what we need in this case.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713' numOctaves='4'/>
    <feDisplacementMap in='SourceGraphic' scale='150' xChannelSelector='R'/>
    <feBlend in2='SourceGraphic'/>
  </filter>
</svg>

I’ve seen a lot of tutorials using feComposite with the default operator of over or feMerge to place layers one on top of another, but feBlend with the default mode of normal produces the exact same result, I find it to be simpler than feMerge in the case of just two layers and it’s fewer characters than feComposite.

To get rid of the opaque pixels outside the initial gradient box, we restrict the filter region to its exact input box — starting from the 0,0 point of this input and covering 100% of it along both the x and y axis (by default, the filter region starts from -10%,-10% and covers 120% of the input box along each of the two axes). This means explicitly setting the x, y, width and height attributes:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB' 
	  x='0' y='0' width='1' height='1'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713' numOctaves='4'/>
    <feDisplacementMap in='SourceGraphic' scale='150' xChannelSelector='R'/>
    <feBlend in2='SourceGraphic'/>
  </filter>
</svg>

Another option to get rid of this second problem would be to use clip-path: inset(0) on the element we apply this grainy filter on. This is one situation where it’s convenient that clip-path gets applied after filter (the order in the CSS doesn’t matter here).

.grad-box {
  background: linear-gradient(90deg, #a9613a, #1e1816);
  clip-path: inset(0);
  filter: url(#grain)
}
Grainy gradient with sharp edges, no tansparent pixels within, no opaque pixels outside.
the desired result

A problem with this solution

The inconvenient part about this filter is that it applies to the entire element, not just its gradient background. And maybe we want this element to also have text content and a box-shadow. Consider the case when before applying the filter we set a box-shadow and add text content:

Card with a banded gradient, text and box-shadow.
the case when we also have a shadow and text

In this case, applying the filter to the entire element causes all kinds of problems. The text “dissolves” into the gradient, the black box-shadow outside the box has some pixels displaced inside the box over the gradient – this is really noticeable in the brighter parts of this gradient. Furthermore, if we were to use the clip-path fix for the gradient pixels displaced outside the initial gradient box, this would also cut away the outer shadow.

Previous card with a banded gradient, text and box-shadow, now with a filter pplied on it too. This has unpleasant side effects as dscribed above.
problems arising when we apply the grainy filter on the entire element

The current solution would be to put this gradient in an absolutely positioned pseudo behind the text content (z-index: -1), covering the entire padding-box of its parent (inset: 0). This separates the parent’s box-shadow and text from the gradient on the pseudo, so applying the filter on the pseudo doesn’t affect the parent’s box-shadow and text.

.grad-box { /* relevant styles */
  positon: relative; /* needed for absolutely positioned pseudo */
  box-shadow: -2px 2px 8px #000;
	
  &::before {
    position: absolute;
    inset: 0;
    z-index: -1;
    background: linear-gradient(90deg, #a9613a, #1e1816);
    filter: url(#grain);
    clip-path: inset(0);
    content: '' /* pseudo won't show up without it */
  }
}
Previous card with a gradient, text and box-shadow, except now the gradient is grain, which fixes the banding issue.
the desired result when having a shadow and text content (live demo)

Improving things for the future

While this works fine, it doesn’t feel ideal to have to use up a pseudo we might need for something else and, ugh, also have to add all the styles for positioning it along all three axes (the z axis is included here too because we need to place the pseudo behind the text content).

And we do have a better option! We can apply the filter only on the gradient background layer using the filter() function.

This is not the same as the filter property! It’s a function that outputs an image and takes as arguments an image (which can be a CSS gradient too) and a filter chain. And it can be used anywhere we can use an image in CSS — as a background-imageborder-imagemask-image… even shape-outside!

In our particular case, this would simplify the code as follows:

.grad-box { /* relevant styles */
  box-shadow: -2px 2px 8px #000;
  background: filter(linear-gradient(90deg, #a9613a, #1e1816), url(#grain));
}

Note that in this case we must restrict the filter region from the <filter> element attributes, otherwise we run into a really weird bug in the one browser supporting this, Safari.

Safari problem: it's trying to fit the filter output, including what goes outside the input image box, into the input image box, basically scaling down the image to make room for its pixels displaced outside its initial boundary by the filter.
the Safari problem when we don’t restrict the filter region

Because, while Safari has supported the filter() function since 2015, for about a decade, sadly no other browser has followed. There are bugs open for both Chrome and Firefox in case anyone wants to show interest in them implementing this.

Here is the live demo, but keep in mind it only works in Safari.

This would come in really handy not just for the cases when we want to have text content or visual touches (like box-shadow) that remain unaffected by the noise filter, but especially for masking. Banding is always a problem when using radial-gradient() for a mask and, while we can layer multiple (pseudo)elements instead of background layers and/ or borders, masking is a trickier problem.

For example, consider a conic spotlight. That is, a conic-gradient() masked by a radial one. In this case, it would really help us to be able to apply a grain filter directly to the mask gradient.

.conic-spotlight {
  background: 
    conic-gradient(from 180deg - .5*$a at 50% 0%, 
                   $side-c, #342443, $side-c $a);
  mask: 
    filter(
      radial-gradient(circle closest-side, red, 65%, #0000), 
      url(#grain))
}

In this particular case, the grain filter is even simpler, as we don’t need to layer the non-grainy input gradient underneath the grainy one (so we ditch that final feBlend primitive). Again, remember we need to restrict the filter region from the <filter> element attributes.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB' x='0' y='0' width='1' height='1'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713'/>
    <feDisplacementMap in='SourceGraphic' scale='40' xChannelSelector='R'/>
  </filter>
</svg>

Here is the live demo. Keep in mind it only works in Safari.

Since we can’t yet do this cross-browser, our options depend today on our constraints, the exact result we’re going for.

Do we need an image backdrop behind the spotlight? In this case, we apply the radial mask on the .conic-spotlight element and, since, just like clip-path, mask gets applied after filter, we add a wrapper around this element to set the filter on it. Alternatively, we could set the conic spotlight background and the radial mask on a pseudo of our .conic-spotlight and set the filter on the actual element.

.conic-spotlight {
  display: grid;
  filter: url(#grain);
	
  &::before {
    background: 
      conic-gradient(from 180deg - .5*$a at 50% 0%, 
                     $side-c, #342443, $side-c $a);
    mask: radial-gradient(circle closest-side, red, 65%, #0000);
    content: ''
  }
}

If however we only need a solid backdrop (a black one for example), then we could use a second gradient layer as a radial cover on top of the conic-gradient():

body { background: $back-c }

.conic-spotlight {
  background:
    radial-gradient(circle closest-side, #0000, 65%, $back-c), 
    conic-gradient(from 180deg - .5*$a at 50% 0%, 
                   $side-c, #342443, $side-c $a);
  filter: url(#grain)
}

Note that neither of these two emulate the Safari-only demo exactly because they apply the grain filter on the whole thing, not just on the radial-gradient() (which allows us to get rid of the mask banding, but preserve it for the conic-gradient() to give the radiating rays effect). We could tweak the second approach to make the cover a separate pseudo-element instead of a background layer and apply the grain filter just on that pseudo, but it’s still more complicated than the filter() approach. Which is why it would be very good to have it cross-browser.

Some more examples

Let’s see a few more interesting demos where we’ve made visuals grainy!

Grainy image shadows

a grid of square images, each with a grainy shadow that's a blurred copy of itself
realistic grainy image shadows

Shadows or blurred elements can also exhibit banding issues where their edges fade. In this demo, we’re using a slightly more complex filter to first blur and offset the input image, then using the feTurbulence and feDisplacementMap combination to make this blurred and offset input copy grainy. We also decrease its alpha a tiny little bit (basically multiplying it with .9). Finally, we’re placing the original filter input image on top of this blurred, offset, grainy and slightly faded copy.

- let d = .1;

svg(width='0' height='0' aria-hidden='true')
  filter#shadow(x='-100%' y='-100%' width='300%' height='300%'
                color-interpolation-filters='sRGB'
                primitiveUnits='objectBoundingBox')
    //- blur image
    feGaussianBlur(stdDeviation=d)
    //- then offset it and save it as 'in'
    feOffset(dx=d dy=d result='in')
    //- generate noise
    feTurbulence(type='fractalNoise' baseFrequency='.9713')
    //- use noise as displacement map to scramble a bit the blurred & offset image
    feDisplacementMap(in='in' scale=2*d xChannelSelector='R')
    //- decrease alpha a little bit
    feComponentTransfer
      feFuncA(type='linear' slope='.9')
    //- add original image on top
    feBlend(in='SourceGraphic')

Since our input images are square here, we can use relative length values (by setting primitiveUnits to ObjectBoundingBox) and still get the same result cross-browser. A relative offset of 1 is equal to the square image edge length, both for the dx and dy attributes of feOffset and for the scale attribute of feDisplacementMap.

In our case, the dx and dy offsets being set to .1 means we offset the blurred square image copy by 10% of its edge length along each of the two axes. And the displacement scale being set to .2 means any pixel of the blurred and offset copy may be displaced by at most half of that (half being 10% of the square image edge), with plus or with minus, along both the x and y axes. And it gets displaced by that much when the selected channel (given by xChannelSelector and yChannelSelector) of the corresponding map pixel is either zeroed (in which case it’s displaced in the positive direction) or maxed out (negative displacement).

The shadow doesn’t need to be a copy of the input image, it can also be a plain rectangle:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='shadow' x='-50%' y='-50%' width='200%' height='200%'
          color-interpolation-filters='sRGB'
          primitiveUnits='objectBoundingBox'>
    <!-- flood entire filter region with orangered -->
    <feFlood flood-color='orangered'/>
    <!-- restrict to rectangle of filter input (our image)  -->
    <feComposite in2='SourceAlpha' operator='in'/>
    <!-- blur and everything else just like before  -->
  </filter>
</svg>

Grainy image fade

This is pretty similar to the previous demo, except what we displace are the semi-transparent fading edge pixels obtained using a blur. And we obviously don’t layer the original image on top.

There are a couple more little tricks used here to get things just right, but they’re outside the scope of this article, so we’re not going into them here.

Noisy gradient discs

These are created with SVG <circle> elements just so we can use SVG radial gradients for them. Compared to CSS radial-grdient(), SVG radialGradient has the advantage of allowing us to specify a focal point (via fx and fy), which allows us to create radial gradients not possible with pure CSS.

The filter is a bit more complex here because the aim was to create a specific type of noise, but the main idea is the same.

Animated single img gradient glow border

a grid of images with total or partial gradient borders, each having a glow, which is a grainy glow for every second image
screenshot (live demo)

Animated gradient glow borders seem to be all the rage nowadays, which is something I never imagined woukd happen when I first started playing with them almost a decade ago. But wherever there’s a fade effect like a glow, we may get banding. It’s pretty subtle in this case, but the grainy glow looks better than the no grain version.

Grainy CSS backgrounds

Another example would be this one, where I’m layering a bunch of linear gradients along the circumradii to the corners of a regular polygon in order to emulate a mesh gradient. Even when blending these gradients, subtle banding is still noticeable. Applying our standard grain filter discussed earlier fixes this problem.

Also, since we’re using clip-path to get the polygon shape and this is applied after the filter, we don’t need to worry about opaque pixels displaced outside the polygon shape by our grain filter. This means we don’t need to bother with setting the filter region via the <filter> element attributes.

Grainy SVG backgrounds

The idea here is we layer a bunch of different SVG shapes, give them various fills (plain, linearGradient or radialGradient ones), blur them and then finally apply a grain filter.

a 3⨯3 grid of grainy abstract backgrounds
grainy SVG backgrounds (live demo)
]]>
https://frontendmasters.com/blog/grainy-gradients/feed/ 4 6066
SVG to shape() https://frontendmasters.com/blog/svg-to-shape/ https://frontendmasters.com/blog/svg-to-shape/#respond Wed, 04 Jun 2025 11:23:09 +0000 https://frontendmasters.com/blog/?p=6042 in SVG ported to CSS so it can use actual units. It’s probably how path() should have ported to begin with, but c’est la vie. I’ll make the point in this demo. Resize those […]]]> We’ve been trying to make the point around here that the new shape() in CSS is awesome. It’s the powerful <path> in SVG ported to CSS so it can use actual units. It’s probably how path() should have ported to begin with, but c’est la vie.

I’ll make the point in this demo. Resize those containers and see how the clip-path responds (path() cannot be fluid, shape() can).

Are SVG <path>s 1-to-1 convertible to shape(), though? Apparently they are! (!!). Two people have already built converters and from what I’ve tested they work great.

]]>
https://frontendmasters.com/blog/svg-to-shape/feed/ 0 6042
Layered Text Headers https://frontendmasters.com/blog/layered-text-headers/ https://frontendmasters.com/blog/layered-text-headers/#comments Mon, 24 Mar 2025 15:55:40 +0000 https://frontendmasters.com/blog/?p=5448 There is a way to apply a stroke to web text across all browsers:

.stroke-text {
  -webkit-text-stroke: 5px red;
}

Despite the vendor prefix there, it works across all browsers.

But I’ve never liked it… until recently.

I’m not sure if I’ve ever spelled out exactly why, so first let me do that. Here’s a nice R from Roslindale.

Black letter "R" from Roslindale font.

Which is made up of vector points like all fonts:

The same letter R as above except with blue points showing off the vector points of the letterform. Screenshot from Adobe Illustrator.

In an application like Adobe Illustrator which gives me control over how a vector shape applies a stroke, if I stroke the outside it ends up looking OK as the integrity of the letterform is preserved.

Gray stroke applied to the outside of the letter "R" from Roslindale.

It’s a bit harder to do with Roslindale “R” with the narrow passages at the top and middle of the letterform here, but we can apply stroke to the inside as well and the overall shape of the letter stays the same.

Red stroke applied to the inside of the letter "R" from Roslindale. Only 1px of stroke though because of the narrow passages.

But if we apply a stroke to the “middle”, that is, straddled across the edge of the shape such that the stroke is equal on both sides, now we’ve ruined the shape of the letter and it looks like 💩.

Green centered-aligned stroke around letter "R" from Roslindale, wrecking the letterform.

This is even more pronounced as we deal with more text.

Top: Outside stroke
Middle: Inside stroke
Bottom: Center stroke 💩

Point is: center aligned stroke sucks. And guess what the only option is for -webkit-text-stroke? Center-aligned. And, equally sadly, all strokes in SVG, a bonkers omission for a vector painting specific language.

Alas there is a half decent and kinda fun workaround. The trick is essentially using paint-order (which works in CSS and SVG) to make sure that the “fill” of the element is drawn on top of the “stroke”, which effectively makes the stroke appear as if it’s outside-aligned even if it’s not actually doing that.

.stroke-text {
  paint-order: stroke fill; /* fill is on top */
  -webkit-text-stroke: 5px red;
}

With this combo we can make stroked text tolerable:

Just putting that fill on top can fix some different awkward situations. Here’s Adam Argyle showing how a text-shadow can really interfere, and then fixing it by forcing that fill layer back on top again.

Wes Bos showed this off, layering on some text-shadow stuff as well, which resulted in a great look:

I had a play as well, and I really like the combination of being able to use a text stroke safely and being able to use a text shadow for an additional effect. Here the shadow comes up on top of the text giving it an actual embossed look:

You could have a play with this one, adding more text shadows or adjusting colors or whatever to get a nice look.

Perhaps goes without saying but I’ll say it anyway: thicker typefaces are going to generally work better with this.

]]>
https://frontendmasters.com/blog/layered-text-headers/feed/ 2 5448
A Deep Dive into the Inline Background Overlap Problem https://frontendmasters.com/blog/overlapping-inline-backgrounds/ https://frontendmasters.com/blog/overlapping-inline-backgrounds/#comments Tue, 18 Mar 2025 17:54:44 +0000 https://frontendmasters.com/blog/?p=5330 tweet by Lucas Bonomi got me thinking about this problem: how to get a semitransparent background following some inline text with padding, but without the overlap problem that can be seen in the image below.

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

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

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

The initial problem and exact particular solution

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

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

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

The result of the above code looks as follows:

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

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

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

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

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

<svg width='0' height='0' aria-hidden='true'>
  <filter id='alpha'>
    <!-- filter content goes here -->
  </filter>
</svg>
svg[height='0'][aria-hidden='true'] { position: fixed }

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

<svg width='0' height='0' aria-hidden='true'>
  <filter id='alpha'>
    <feComponentTransfer in='SourceAlpha' result='back'>
      <feFuncA type='linear' slope='.7'/>
    </feComponentTransfer>
  </filter>
</svg>

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

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

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

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

<svg width="0" height="0" aria-hidden="true">
  <filter id="alpha">
    <feComponentTransfer in="SourceAlpha" result="back">
      <feFuncA type="linear" slope=".7" />
    </feComponentTransfer>
    <feColorMatrix
      in="SourceGraphic"
      values="0 0 0 0 1 
              0 0 0 0 1 
              0 0 0 0 1 
              0 1 0 0 0"
    />
  </filter>
</svg>

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

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

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

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

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

We now have just the white text:

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

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

<svg width="0" height="0" aria-hidden="true">
  <filter id="alpha">
    <feComponentTransfer in="SourceAlpha" result="back">
      <feFuncA type="linear" slope=".7" />
    </feComponentTransfer>
    <feColorMatrix
      in="SourceGraphic"
      values="0 0 0 0 1 
              0 0 0 0 1 
              0 0 0 0 1 
              0 1 0 0 0"
    />
    <feBlend in2="back" />
  </filter>
</svg>

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

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

Cool, right?

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

Expanding the problem scope

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Set RGBA values in the SVG filter

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

<svg width="0" height="0" aria-hidden="true">
  <filter id="alpha" color-interpolation-filters="sRGB">
    <feColorMatrix
      values="0 0 0 0  .098 
              0 0 0 0  .098 
              0 0 0 0  .439 
              0 0 0 .7 0"
      result="back"
    />
    <feColorMatrix
      in="SourceGraphic"
      values="0 0 0 0 1 
              0 0 0 0 .647 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feBlend in2="back" />
  </filter>
</svg>

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

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

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

Let’s try another approach!

Set RGBA values upstream of the SVG filter

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

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

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

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

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

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

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

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

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

p:nth-child(2) {
  --back-c: var(--back-c-2);
  --text-c: var(--text-c-2)
}

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

the problem seen in DevTools

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

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

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

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

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

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

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

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

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

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

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

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

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

feFuncA(type='table' tableValues=v.join(' '))

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, the feComponentTransfer messes up the text for lower value alphas and the lower the value, the worse the problem looks.

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

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

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

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

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

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

0 .2 .4 .6 .8 1

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

0 .2 .2 .6 .8 1

In general, that means our values array would become:

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

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

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

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

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

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

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

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

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

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

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

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

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

particular case of the second to last palette

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

particular case of the final palette

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
  </filter>
</svg>

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

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

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

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

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

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

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

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

how dilation works in the general case

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

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

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

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

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

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

There are two things to keep in mind here.

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

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

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

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

how erosion works in the general case

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

<feMorphology radius='.01'/>

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

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

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

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

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
  </filter>
</svg>

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

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

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

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

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

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
  </filter>
</svg>

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

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

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

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

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

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
  </filter>
</svg>

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

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

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

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

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

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

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

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

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

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

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

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

It finally looks like we’re getting somewhere!

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

Next, we can move on to the text!

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

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

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

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

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
  </filter>
</svg>

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

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

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

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

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

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

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

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

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

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

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

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

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
  </filter>
</svg>

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

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

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

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

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

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

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

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

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

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

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

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="opaque" operator="in" />
  </filter>
</svg>

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

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

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

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

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

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="opaque" operator="in" />
    <feComposite in="text-rgb" operator="in" />
  </filter>
</svg>
Screenshot showing the golden fll layer kept just at the intersection with the text shape.
the golden text of the span

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

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

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

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

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

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="opaque" operator="in" />
    <feComposite in="text-rgb" operator="in" />
    <feBlend in2="back" />
  </filter>
</svg>

This is our final result!

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

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

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

]]>
https://frontendmasters.com/blog/overlapping-inline-backgrounds/feed/ 4 5330
Mesh Gradient Generator https://frontendmasters.com/blog/mesh-gradient-generator/ https://frontendmasters.com/blog/mesh-gradient-generator/#respond Thu, 20 Feb 2025 15:51:41 +0000 https://frontendmasters.com/blog/?p=5210 A nice tool for generating mesh gradients from Erik D. Kennedy. You might call it a bit of a trend, but as Erik pointed out in a recent newsletter, they can be quite versatile because it’s just a nice background look that doesn’t demand anything in particular from the brand.

Mesh gradients can work across a huge variety of brands.

  • Dark? Yes.
  • Light? Yes.
  • Grainy? Sure.
  • Pensive? Absolutely.
  • Delicate? Yup.
  • Weatherworn and rugged? Totally.
]]>
https://frontendmasters.com/blog/mesh-gradient-generator/feed/ 0 5210