mask-clip – CSS-Tricks https://css-tricks.com Tips, Tricks, and Techniques on using Cascading Style Sheets. Thu, 26 May 2022 14:13:03 +0000 en-US hourly 1 https://wordpress.org/?v=6.1.1 https://i0.wp.com/css-tricks.com/wp-content/uploads/2021/07/star.png?fit=32%2C32&ssl=1 mask-clip – CSS-Tricks https://css-tricks.com 32 32 45537868 Cool CSS Hover Effects That Use Background Clipping, Masks, and 3D https://css-tricks.com/css-hover-effects-background-masks-3d/ https://css-tricks.com/css-hover-effects-background-masks-3d/#comments Thu, 26 May 2022 14:13:01 +0000 https://css-tricks.com/?p=365946 We’ve walked through a series of posts now about interesting approaches to CSS hover effects. We started with a bunch of examples that use CSS background properties, then moved on to the text-shadow property where we technically didn’t use


Cool CSS Hover Effects That Use Background Clipping, Masks, and 3D originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
We’ve walked through a series of posts now about interesting approaches to CSS hover effects. We started with a bunch of examples that use CSS background properties, then moved on to the text-shadow property where we technically didn’t use any shadows. We also combined them with CSS variables and calc() to optimize the code and make it easy to manage.

In this article, we will build off those two articles to create even more complex CSS hover animations. We’re talking about background clipping, CSS masks, and even getting our feet wet with 3D perspectives. In other words, we are going to explore advanced techniques this time around and push the limits of what CSS can do with hover effects!

Cool Hover Effects series:

  1. Cool Hover Effects That Use Background Properties
  2. Cool Hover Effects That Use CSS Text Shadow
  3. Cool Hover Effects That Use Background Clipping, Masks, and 3D (you are here!)

Here’s just a taste of what we’re making:

Hover effects using background-clip

Let’s talk about background-clip. This CSS property accepts a text keyword value that allows us to apply gradients to the text of an element instead of the actual background.

So, for example, we can change the color of the text on hover as we would using the color property, but this way we animate the color change:

All I did was add background-clip: text to the element and transition the background-position. Doesn’t have to be more complicated than that!

But we can do better if we combine multiple gradients with different background clipping values.

In that example, I use two different gradients and two values with background-clip. The first background gradient is clipped to the text (thanks to the text value) to set the color on hover, while the second background gradient creates the bottom underline (thanks to the padding-box value). Everything else is straight up copied from the work we did in the first article of this series.

How about a hover effect where the bar slides from top to bottom in a way that looks like the text is scanned, then colored in:

This time I changed the size of the first gradient to create the line. Then I slide it with the other gradient that update the text color to create the illusion! You can visualize what’s happening in this pen:

We’ve only scratched the surface of what we can do with our background-clipping powers! However, this technique is likely something you’d want to avoid using in production, as Firefox is known to have a lot of reported bugs related to background-clip. Safari has support issues as well. That leaves only Chrome with solid support for this stuff, so maybe have it open as we continue.

Let’s move on to another hover effect using background-clip:

You’re probably thinking this one looks super easy compared to what we’ve just covered — and you are right, there’s nothing fancy here. All I am doing is sliding one gradient while increasing the size of another one.

But we’re here to look at advanced hover effects, right? Let’s change it up a bit so the animation is different when the mouse cursor leaves the element. Same hover effect, but a different ending to the animation:

Cool right? let’s dissect the code:

.hover {
  --c: #1095c1; /* the color */

  color: #0000;
  background: 
    linear-gradient(90deg, #fff 50%, var(--c) 0) calc(100% - var(--_p, 0%)) / 200%, 
    linear-gradient(var(--c) 0 0) 0% 100% / var(--_p, 0%) no-repeat,
    var(--_c, #0000);
  -webkit-background-clip: text, padding-box, padding-box;
          background-clip: text, padding-box, padding-box;
  transition: 0s, color .5s, background-color .5s;
}
.hover:hover {
  color: #fff;
  --_c: var(--c);
  --_p: 100%;
  transition: 0.5s, color 0s .5s, background-color 0s .5s;
}

We have three background layers — two gradients and the background-color defined using --_c variable which is initially set to transparent (#0000). On hover, we change the color to white and the --_c variable to the main color (--c).

Here’s what is happening on that transition: First, we apply a transition to everything but we delay the color and background-color by 0.5s to create the sliding effect. Right after that, we change the color and the background-color. You might notice no visual changes because the text is already white (thanks to the first gradient) and the background is already set to the main color (thanks to the second gradient).

Then, on mouse out, we apply an instant change to everything (notice the 0s delay), except for the color and background-color that have a transition. This means that we put all the gradients back to their initial states. Again, you will probably see no visual changes because the text color and background-color already changed on hover.

Lastly, we apply the fading to color and a background-color to create the mouse-out part of the animation. I know, it may be tricky to grasp but you can better visualize the trick by using different colors:

Hover the above a lot of times and you will see the properties that are animating on hover and the ones animating on mouse out. You can then understand how we reached two different animations for the same hover effect.

Let’s not forget the DRY switching technique we used in the previous articles of this series to help reduce the amount of code by using only one variable for the switch:

.hover {
  --c: 16 149 193; /* the color using the RGB format */

  color: rgb(255 255 255 / var(--_i, 0));
  background:
    /* Gradient #1 */
    linear-gradient(90deg, #fff 50%, rgb(var(--c)) 0) calc(100% - var(--_i, 0) * 100%) / 200%,
    /* Gradient #2 */
    linear-gradient(rgb(var(--c)) 0 0) 0% 100% / calc(var(--_i, 0) * 100%) no-repeat,
    /* Background Color */
    rgb(var(--c)/ var(--_i, 0));
  -webkit-background-clip: text, padding-box, padding-box;
          background-clip: text, padding-box, padding-box;
  --_t: calc(var(--_i,0)*.5s);
  transition: 
    var(--_t),
    color calc(.5s - var(--_t)) var(--_t),
    background-color calc(.5s - var(--_t)) var(--_t);
}
.hover:hover {
  --_i: 1;
}

If you’re wondering why I reached for the RGB syntax for the main color, it’s because I needed to play with the alpha transparency. I am also using the variable --_t to reduce a redundant calculation used in the transition property.

Before we move to the next part here are more examples of hover effects I did a while ago that rely on background-clip. It would be too long to detail each one but with what we have learned so far you can easily understand the code. It can be a good inspiration to try some of them alone without looking at the code.

I know, I know. These are crazy and uncommon hover effects and I realize they are too much in most situations. But this is how to practice and learn CSS. Remember, we pushing the limits of CSS hover effects. The hover effect may be a novelty, but we’re learning new techniques along the way that can most certainly be used for other things.

Hover effects using CSS mask

Guess what? The CSS mask property uses gradients the same way the background property does, so you will see that what we’re making next is pretty straightforward.

Let’s start by building a fancy underline.

I’m using background to create a zig-zag bottom border in that demo. If I wanted to apply an animation to that underline, it would be tedious to do it using background properties alone.

Enter CSS mask.

The code may look strange but the logic is still the same as we did with all the previous background animations. The mask is composed of two gradients. The first gradient is defined with an opaque color that covers the content area (thanks to the content-box value). That first gradient makes the text visible and hides the bottom zig-zag border. content-box is the mask-clip value which behaves the same as background-clip

linear-gradient(#000 0 0) content-box

The second gradient will cover the whole area (thanks to padding-box). This one has a width that’s defined using the --_p variable, and it will be placed on the left side of the element.

linear-gradient(#000 0 0) 0 / var(--_p, 0%) padding-box

Now, all we have to do is to change the value of --_p on hover to create a sliding effect for the second gradient and reveal the underline.

.hover:hover {
  --_p: 100%;
  color: var(--c);
}

The following demo uses with the mask layers as backgrounds to better see the trick taking place. Imagine that the green and red parts are the visible parts of the element while everything else is transparent. That’s what the mask will do if we use the same gradients with it.

With such a trick, we can easily create a lot of variation by simply using a different gradient configuration with the mask property:

Each example in that demo uses a slightly different gradient configuration for the mask. Notice, too, the separation in the code between the background configuration and the mask configuration. They can be managed and maintained independently.

Let’s change the background configuration by replacing the zig-zag underline with a wavy underline instead:

Another collection of hover effects! I kept all the mask configurations and changed the background to create a different shape. Now, you can understand how I was able to reach 400 hover effects without pseudo-elements — and we can still have more!

Like, why not something like this:

Here’s a challenge for you: The border in that last demo is a gradient using the mask property to reveal it. Can you figure out the logic behind the animation? It may look complex at first glance, but it’s super similar to the logic we’ve looked at for most of the other hover effects that rely on gradients. Post your explanation in the comments!

Hover effects in 3D

You may think it’s impossible to create a 3D effect with a single element (and without resorting to pseudo-elements!) but CSS has a way to make it happen.

What you’re seeing there isn’t a real 3D effect, but rather a perfect illusion of 3D in the 2D space that combines the CSS background, clip-path, and transform properties.

Breakdown of the CSS hover effect in four stages.
The trick may look like we’re interacting with a 3D element, but we’re merely using 2D tactics to draw a 3D box

The first thing we do is to define our variables:

--c: #1095c1; /* color */
--b: .1em; /* border length */
--d: 20px; /* cube depth */

Then we create a transparent border with widths that use the above variables:

--_s: calc(var(--d) + var(--b));
color: var(--c);
border: solid #0000; /* fourth value sets the color's alpha */
border-width: var(--b) var(--b) var(--_s) var(--_s);

The top and right sides of the element both need to equal the --b value while the bottom and left sides need to equal to the sum of --b and --d (which is the --_s variable).

For the second part of the trick, we need to define one gradient that covers all the border areas we previously defined. A conic-gradient will work for that:

background: conic-gradient(
  at left var(--_s) bottom var(--_s),
  #0000 90deg,var(--c) 0
 ) 
 0 100% / calc(100% - var(--b)) calc(100% - var(--b)) border-box;
Diagram of the sizing used for the hover effect.

We add another gradient for the third part of the trick. This one will use two semi-transparent white color values that overlap the first previous gradient to create different shades of the main color, giving us the illusion of shading and depth.

conic-gradient(
  at left var(--d) bottom var(--d),
  #0000 90deg,
  rgb(255 255 255 / 0.3) 0 225deg,
  rgb(255 255 255 / 0.6) 0
) border-box
Showing the angles used to create the hover effect.

The last step is to apply a CSS clip-path to cut the corners for that long shadow sorta feel:

clip-path: polygon(
  0% var(--d), 
  var(--d) 0%, 
  100% 0%, 
  100% calc(100% - var(--d)), 
  calc(100% - var(--d)) 100%, 
  0% 100%
)
Showing the coordinate points of the three-dimensional cube used in the CSS hover effect.

That’s all! We just made a 3D rectangle with nothing but two gradients and a clip-path that we can easily adjust using CSS variables. Now, all we have to do is to animate it!

Notice the coordinates from the previous figure (indicated in red). Let’s update those to create the animation:

clip-path: polygon(
  0% var(--d), /* reverses var(--d) 0% */
  var(--d) 0%, 
  100% 0%, 
  100% calc(100% - var(--d)), 
  calc(100% - var(--d)) 100%, /* reverses 100% calc(100% - var(--d)) */ 
  0% 100% /* reverses var(--d) calc(100% - var(--d)) */
)

The trick is to hide the bottom and left parts of the element so all that’s left is a rectangular element with no depth whatsoever.

This pen isolates the clip-path portion of the animation to see what it’s doing:

The final touch is to move the element in the opposite direction using translate — and the illusion is perfect! Here’s the effect using different custom property values for varying depths:

The second hover effect follows the same structure. All I did is to update a few values to create a top left movement instead of a top right one.

Combining effects!

The awesome thing about everything we’ve covered is that they all complement each other. Here is an example where I am adding the text-shadow effect from the second article in the series to the background animation technique from the first article while using the 3D trick we just covered:

The actual code might be confusing at first, but go ahead and dissect it a little further — you’ll notice that it’s merely a combination of those three different effects, pretty much smushed together.

Let me finish this article with a last hover effect where I am combining background, clip-path, and a dash of perspective to simulate another 3D effect:

I applied the same effect to images and the result was quite good for simulating 3D with a single element:

Want a closer look at how that last demo works? I wrote something up on it.

Wrapping up

Oof, we are done! I know, it’s a lot of tricky CSS but (1) we’re on the right website for that kind of thing, and (2) the goal is to push our understanding of different CSS properties to new levels by allowing them to interact with one another.

You may be asking what the next step is from here now that we’re closing out this little series of advanced CSS hover effects. I’d say the next step is to take all that we learned and apply them to other elements, like buttons, menu items, links, etc. We kept things rather simple as far as limiting our tricks to a heading element for that exact reason; the actual element doesn’t matter. Take the concepts and run with them to create, experiment with, and learn new things!


Cool CSS Hover Effects That Use Background Clipping, Masks, and 3D originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/css-hover-effects-background-masks-3d/feed/ 5 365946
Icon Glassmorphism Effect in CSS https://css-tricks.com/icon-glassmorphism-effect-in-css/ https://css-tricks.com/icon-glassmorphism-effect-in-css/#comments Mon, 08 Nov 2021 14:57:42 +0000 https://css-tricks.com/?p=322098 I recently came across a cool effect known as glassmorphism in a Dribble shot. My first thought was I could quickly recreate it in a few minutes if I just use some emojis for the icons without wasting time …


Icon Glassmorphism Effect in CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
I recently came across a cool effect known as glassmorphism in a Dribble shot. My first thought was I could quickly recreate it in a few minutes if I just use some emojis for the icons without wasting time on SVG-ing them.

Animated gif. Shows a nav bar with four grey icons. On :hover/ :focus, a tinted icon slides and rotates, partly coming out from behind the grey one. In the area where they overlap, we have a glassmorphism effect, with the icon in the back seen as blurred through the semitransparent grey one in front.
The effect we’re after.

I couldn’t have been more wrong about those “few minutes” — they ended up being days of furiously and frustratingly scratching this itch!

It turns out that, while there are resources on how to CSS such an effect, they all assume the very simple case where the overlay is rectangular or at most a rectangle with border-radius. However, getting a glassmorphism effect for irregular shapes like icons, whether these icons are emojis or proper SVGs, is a lot more complicated than I expected, so I thought it would be worth sharing the process, the traps I fell into and the things I learned along the way. And also the things I still don’t understand.

Why emojis?

Short answer: because SVG takes too much time. Long answer: because I lack the artistic sense of just drawing them in an image editor, but I’m familiar with the syntax enough such that I can often compact ready-made SVGs I find online to less than 10% of their original size. So, I cannot just use them as I find them on the internet — I have to redo the code to make it super clean and compact. And this takes time. A lot of time because it’s detail work.

And if all I want is to quickly code a menu concept with icons, I resort to using emojis, applying a filter on them in order to make them match the theme and that’s it! It’s what I did for this liquid tab bar interaction demo — those icons are all emojis! The smooth valley effect makes use of the mask compositing technique.

Animated gif. Shows a white liquid navigation bar with five items, one of which is selected. The selected one has a smooth valley at the top, with a dot levitating above it. It's also black, while the non-selected ones are grey in the normal state and beige in the :hover/ :focus state. On clicking another icon, the selection smoothly changes as the valley an the levitating dot slide to always be above the currently selected item.
Liquid navigation.

Alright, so this is going to be our starting point: using emojis for the icons.

The initial idea

My first thought was to stack the two pseudos (with emoji content) of the navigation links, slightly offset and rotate the bottom one with a transform so that they only partly overlap. Then, I’d make the top one semitransparent with an opacity value smaller than 1, set backdrop-filter: blur() on it, and that should be just about enough.

Now, having read the intro, you’ve probably figured out that didn’t go as planned, but let’s see what it looks like in code and what issues there are with it.

We generate the nav bar with the following Pug:

- let data = {
-   home: { ico: '🏠', hue: 200 }, 
-   notes: { ico: '🗒️', hue: 260 }, 
-   activity: { ico: '🔔', hue: 320 }, 
-   discovery: { ico: '🧭', hue: 30 }
- };
- let e = Object.entries(data);
- let n = e.length;

nav
  - for(let i = 0; i > n; i++)
    a(href='#' data-ico=e[i][1].ico style=`--hue: ${e[i][1].hue}deg`) #{e[i][0]}

Which compiles to the HTML below:

<nav>
  <a href='#' data-ico='🏠' style='--hue: 200deg'>home</a>
  <a href='#' data-ico='🗒️' style='--hue: 260deg'>notes</a>
  <a href='#' data-ico='🔔' style='--hue: 320deg'>activity</a>
  <a href='#' data-ico='🧭' style='--hue: 30deg'>iscovery</a>
</nav>

We start with layout, making our elements grid items. We place the nav in the middle, give links explicit widths, put both pseudos for each link in the top cell (which pushes the link text content to the bottom cell) and middle-align the link text and pseudos.

body, nav, a { display: grid; }

body {
  margin: 0;
  height: 100vh;
}

nav {
  grid-auto-flow: column;
  place-self: center;
  padding: .75em 0 .375em;
}

a {
  width: 5em;
  text-align: center;
  
  &::before, &::after {
    grid-area: 1/ 1;
    content: attr(data-ico);
  }
}
Screenshot. Shows the four menu items lined up in a row in the middle of the page, each item occupying a column, all columns having the same width; with emojis above the link text, both middle-aligned horizontally.
Firefox screenshot of the result after we got layout basics sorted.

Note that the look of the emojis is going to be different depending on the browser you’re using view the demos.

We pick a legible font, bump up its size, make the icons even bigger, set backgrounds, and a nicer color for each of the links (based on the --hue custom property in the style attribute of each):

body {
  /* same as before */
  background: #333;
}

nav {
  /* same as before */
  background: #fff;
  font: clamp(.625em, 5vw, 1.25em)/ 1.25 ubuntu, sans-serif;
}

a {
  /* same as before */
  color: hsl(var(--hue), 100%, 50%);
  text-decoration: none;
  
  &::before, &::after {
    /* same as before */
    font-size: 2.5em;
  }
}
Screenshot. Shows the same layout as before, only with a prettier and bigger font and even bigger icons, backgrounds and each menu item having a different color value based on its --hue.
Chrome screenshot of the result (live demo) after prettifying things a bit.

Here’s where things start to get interesting because we start differentiating between the two emoji layers created with the link pseudos. We slightly move and rotate the ::before pseudo, make it monochrome with a sepia(1) filter, get it to the right hue, and bump up its contrast() — an oldie but goldie technique from Lea Verou. We also apply a filter: grayscale(1) on the ::after pseudo and make it semitransparent because, otherwise, we wouldn’t be able to see the other pseudo through it.

a {
  /* same as before */
  
  &::before {
    transform: 
      translate(.375em, -.25em) 
      rotate(22.5deg);
    filter: 
      sepia(1) 
      hue-rotate(calc(var(--hue) - 50deg)) 
      saturate(3);
  }
	
  &::after {
    opacity: .5;
    filter: grayscale(1);
  }
}
Screenshot. Same nav bar as before, only now the top icon layer is grey and semitransparent, while the bottom one is slightly offset and rotated, mono in the specified --hue.
Chrome screenshot of the result (live demo) after differentiating between the two icon layers.

Hitting a wall

So far, so good… so what? The next step, which I foolishly thought would be the last when I got the idea to code this, involves setting a backdrop-filter: blur(5px) on the top (::after) layer.

Note that Firefox still needs the gfx.webrender.all and layout.css.backdrop-filter.enabled flags set to true in about:config in order for the backdrop-filter property to work.

Animated gif. Shows how to find the flags mentioned above (gfx.webrender.all and layout.css.backdrop-filter.enabled) in order to ensure they are set to true. Go to about:config, start typing their name in the search box and double click their value to change it if it's not set to true already.
The flags that are still required in Firefox for backdrop-filter to work.

Sadly, the result looks nothing like what I expected. We get a sort of overlay the size of the entire top icon bounding box, but the bottom icon isn’t really blurred.

Screenshot collage. Shows the not really blurred, but awkward result with an overlay the size of the top emoji box after applying the backdrop-filter property. This happens both in Chrome (top) and in Firefox (bottom).
Chrome (top) and Firefox (bottom) screenshots of the result (live demo) after applying backdrop-filter.

However, I’m pretty sure I’ve played with backdrop-filter: blur() before and it worked, so what the hairy heck is going on here?

Screenshot. Shows a working glassmorphism effect, created via a control panel where we draw some sliders to get the value for each filter function.
Working glassmorphism effect (live demo) in an older demo I coded.

Getting to the root of the problem

Well, when you have no idea whatsoever why something doesn’t work, all you can do is take another working example, start adapting it to try to get the result you want… and see where it breaks!

So let’s see a simplified version of my older working demo. The HTML is just an article in a section. In the CSS, we first set some dimensions, then we set an image background on the section, and a semitransparent one on the article. Finally, we set the backdrop-filter property on the article.

section { background: url(cake.jpg) 50%/ cover; }

article {
  margin: 25vmin;
  height: 40vh;
  background: hsla(0, 0%, 97%, .25);
  backdrop-filter: blur(5px);
}
Screenshot. Shows a working glassmorphism effect, where we have a semitransparent box on top of its parent one, having an image background.
Working glassmorphism effect (live demo) in a simplified test.

This works, but we don’t want our two layers nested in one another; we want them to be siblings. So, let’s make both layers article siblings, make them partly overlap and see if our glassmorphism effect still works.

<article class='base'></article>
<article class='grey'></article>
article { width: 66%; height: 40vh; }

.base { background: url(cake.jpg) 50%/ cover; }

.grey {
  margin: -50% 0 0 33%;
  background: hsla(0, 0%, 97%, .25);
  backdrop-filter: blur(5px);
}
Screenshot collage. Shows the case where we have a semitransparent box on top of its sibling having an image background. The top panel screenshot was taken in Chrome, where the glassmorphism effect works as expected. The bottom panel screenshot was taken in Firefox, where things are mostly fine, but the blur handling around the edges is really weird.
Chrome (top) and Firefox (bottom) screenshots of the result (live demo) when the two layers are siblings.

Everything still seems fine in Chrome and, for the most part, Firefox too. It’s just that the way blur() is handled around the edges in Firefox looks awkward and not what we want. And, based on the few images in the spec, I believe the Firefox result is also incorrect?

I suppose one fix for the Firefox problem in the case where our two layers sit on a solid background (white in this particular case) is to give the bottom layer (.base) a box-shadow with no offsets, no blur, and a spread radius that’s twice the blur radius we use for the backdrop-filter applied on the top layer (.grey). Sure enough, this fix seems to work in our particular case.

Things get a lot hairier if our two layers sit on an element with an image background that’s not fixed (in which case, we could use a layered backgrounds approach to solve the Firefox issue), but that’s not the case here, so we won’t get into it.

Still, let’s move on to the next step. We don’t want our two layers to be two square boxes, we want then to be emojis, which means we cannot ensure semitransparency for the top one using a hsla() background — we need to use opacity.

.grey {
  /* same as before */
  opacity: .25;
  background: hsl(0, 0%, 97%);
}
Screenshot. Shows the case where we have a subunitary opacity on the top layer in order to make it semitransparent, instead of a subunitary alpha value for the semitransparent background.
The result (live demo) when the top layer is made semitransparent using opacity instead of a hsla() background.

It looks like we found the problem! For some reason, making the top layer semitransparent using opacity breaks the backdrop-filter effect in both Chrome and Firefox. Is that a bug? Is that what’s supposed to happen?

Bug or not?

MDN says the following in the very first paragraph on the backdrop-filter page:

Because it applies to everything behind the element, to see the effect you must make the element or its background at least partially transparent.

Unless I don’t understand the above sentence, this appears to suggest that opacity shouldn’t break the effect, even though it does in both Chrome and Firefox.

What about the spec? Well, the spec is a huge wall of text without many illustrations or interactive demos, written in a language that makes reading it about as appealing as sniffing a skunk’s scent glands. It contains this part, which I have a feeling might be relevant, but I’m unsure that I understand what it’s trying to say — that the opacity set on the top element that we also have the backdrop-filter on also gets applied on the sibling underneath it? If that’s the intended result, it surely isn’t happening in practice.

The effect of the backdrop-filter will not be visible unless some portion of element B is semi-transparent. Also note that any opacity applied to element B will be applied to the filtered backdrop image as well.

Trying random things

Whatever the spec may be saying, the fact remains: making the top layer semitransparent with the opacity property breaks the glassmorphism effect in both Chrome and Firefox. Is there any other way to make an emoji semitransparent? Well, we could try filter: opacity()!

At this point, I should probably be reporting whether this alternative works or not, but the reality is… I have no idea! I spent a couple of days around this step and got to check the test countless times in the meanwhile — sometimes it works, sometimes it doesn’t in the exact same browsers, wit different results depending on the time of day. I also asked on Twitter and got mixed answers. Just one of those moments when you can’t help but wonder whether some Halloween ghost isn’t haunting, scaring and scarring your code. For eternity!

It looks like all hope is gone, but let’s try just one more thing: replacing the rectangles with text, the top one being semitransparent with color: hsla(). We may be unable to get the cool emoji glassmorphism effect we were after, but maybe we can get such a result for plain text.

So we add text content to our article elements, drop their explicit sizing, bump up their font-size, adjust the margin that gives us partial overlap and, most importantly, replace the background declarations in the last working version with color ones. For accessibility reasons, we also set aria-hidden='true' on the bottom one.

<article class='base' aria-hidden='true'>Lion 🧡</article>
<article class='grey'>Lion 🖤</article>
article { font: 900 21vw/ 1 cursive; }

.base { color: #ff7a18; }

.grey {
  margin: -.75em 0 0 .5em;
  color: hsla(0, 0%, 50%, .25);
  backdrop-filter: blur(5px);
}
Screenshot collage. Shows the case where we have a semitransparent text layer on top of its identical solid orange text sibling. The top panel screenshot was taken in Chrome, where we get proper blurring, but it's underneath the entire bounding box of the semitransparent top text, not limited to just the actual text. The bottom panel screenshot was taken in Firefox, where things are even worse, with the blur handling around the edges being really weird.
Chrome (top) and Firefox (bottom) screenshots of the result (live demo) when we have two text layers.

There are couple of things to note here.

First, setting the color property to a value with a subunitary alpha also makes emojis semitransparent, not just plain text, both in Chrome and in Firefox! This is something I never knew before and I find absolutely mindblowing, given the other channels don’t influence emojis in any way.

Second, both Chrome and Firefox are blurring the entire area of the orange text and emoji that’s found underneath the bounding box of the top semitransparent grey layer, instead of just blurring what’s underneath the actual text. In Firefox, things look even worse due to that awkward sharp edge effect.

Even though the box blur is not what we want, I can’t help but think it does make sense since the spec does say the following:

[…] to create a “transparent” element that allows the full filtered backdrop image to be seen, you can use “background-color: transparent;”.

So let’s make a test to check what happens when the top layer is another non-rectangular shape that’s not text, but instead obtained with a background gradient, a clip-path or a mask!

Screenshot collage. Shows the case where we have semitransparent non-rectangular shaped layers (obtained with three various methods: gradient background, clip-path and mask) on top of a rectangular siblings. The top panel screenshot was taken in Chrome, where things seem to work fine in the clip-path and mask case, but not in the gradient background case. In this case, everything that's underneath the bounding box of the top element gets blurred, not just what's underneath the visible part. The bottom panel screenshot was taken in Firefox, where, regardless of the way we got the shape, everything underneath its bounding box gets blurred, not just what's underneath the actual shape. Furthermore, in all three cases we have the old awkward sharp edge issue we've had in Firefox before
Chrome (top) and Firefox (bottom) screenshots of the result (live demo) when the top layer is a non-rectangular shape.

In both Chrome and Firefox, the area underneath the entire box of the top layer gets blurred when the shape is obtained with background: gradient() which, as mentioned in the text case before, makes sense per the spec. However, Chrome respects the clip-path and mask shapes, while Firefox doesn’t. And, in this case, I really don’t know which is correct, though the Chrome result does make more sense to me.

Moving towards a Chrome solution

This result and a Twitter suggestion I got when I asked how to make the blur respect the text edges and not those of its bounding box led me to the next step for Chrome: applying a mask clipped to the text on the top layer (.grey). This solution doesn’t work in Firefox for two reasons: one, text is sadly a non-standard mask-clip value that only works in WebKit browsers and, two, as shown by the test above, masking doesn’t restrict the blur area to the shape created by the mask in Firefox anyway.

/* same as before */

.grey {
  /* same as before */
  -webkit-mask: linear-gradient(red, red) text; /* only works in WebKit browsers */
}
Chrome screenshot. Shows two text and emoji layers partly overlapping. The top one is semitransparent, so through it, we can see the layer underneath blurred (by applying a backdrop-filter on the top one).
Chrome screenshot of the result (live demo) when the top layer has a mask restricted to the text area.

Alright, this actually looks like what we want, so we can say we’re heading in the right direction! However, here we’ve used an orange heart emoji for the bottom layer and a black heart emoji for the top semitransparent layer. Other generic emojis don’t have black and white versions, so my next idea was to initially make the two layers identical, then make the top one semitransparent and use filter: grayscale(1) on it.

article { 
  color: hsla(25, 100%, 55%, var(--a, 1));
  font: 900 21vw/ 1.25 cursive;
}

.grey {
  --a: .25;
  margin: -1em 0 0 .5em;
  filter: grayscale(1);
  backdrop-filter: blur(5px);
  -webkit-mask: linear-gradient(red, red) text;
}
Chrome screenshot. Shows two text and emoji layers partly overlapping. The top one is semitransparent, so through it, we can see the layer underneath blurred (by applying a backdrop-filter on the top one). The problem is that applying the grayscale filter on the top semitransparent layer not only affects this layer, but also the blurred area of the layer underneath.
Chrome screenshot of the result (live demo) when the top layer gets a grayscale(1) filter.

Well, that certainly had the effect we wanted on the top layer. Unfortunately, for some weird reason, it seems to have also affected the blurred area of the layer underneath. This moment is where to briefly consider throwing the laptop out the window… before getting the idea of adding yet another layer.

It would go like this: we have the base layer, just like we have so far, slightly offset from the other two above it. The middle layer is a “ghost” (transparent) one that has the backdrop-filter applied. And finally, the top one is semitransparent and gets the grayscale(1) filter.

body { display: grid; }

article {
  grid-area: 1/ 1;
  place-self: center;
  padding: .25em;
  color: hsla(25, 100%, 55%, var(--a, 1));
  font: 900 21vw/ 1.25 pacifico, z003, segoe script, comic sans ms, cursive;
}

.base { margin: -.5em 0 0 -.5em; }

.midl {
  --a: 0;
  backdrop-filter: blur(5px);
  -webkit-mask: linear-gradient(red, red) text;
}

.grey { filter: grayscale(1) opacity(.25) }
Chrome screenshot. Shows two text and emoji layers partly overlapping. The top one is semitransparent grey, so through it, we can see the layer underneath blurred (by applying a backdrop-filter on a middle, completely transparent one).
Chrome screenshot of the result (live demo) with three layers.

Now we’re getting somewhere! There’s just one more thing left to do: make the base layer monochrome!

/* same as before */

.base {
  margin: -.5em 0 0 -.5em;
  filter: sepia(1) hue-rotate(165deg) contrast(1.5);
}
Chrome screenshot. Shows two text and emoji layers partly overlapping. The bottom one is mono (bluish in this case) and blurred at the intersection with the semitransparent grey one on top.
Chrome screenshot of the result (live demo) we were after.

Alright, this is the effect we want!

Getting to a Firefox solution

While coding the Chrome solution, I couldn’t help but think we may be able to pull off the same result in Firefox since Firefox is the only browser that supports the element() function. This function allows us to take an element and use it as a background for another element.

The idea is that the .base and .grey layers will have the same styles as in the Chrome version, while the middle layer will have a background that’s (via the element() function) a blurred version of our layers.

To make things easier, we start with just this blurred version and the middle layer.

<article id='blur' aria-hidden='true'>Lion 🦁</article>
<article class='midl'>Lion 🦁</article>

We absolutely position the blurred version (still keeping it in sight for now), make it monochrome and blur it and then use it as a background for .midl.

#blur {
  position: absolute;
  top: 2em; right: 0;
  margin: -.5em 0 0 -.5em;
  filter: sepia(1) hue-rotate(165deg) contrast(1.5) blur(5px);
}

.midl {
  --a: .5;
  background: -moz-element(#blur);
}

We’ve also made the text on the .midl element semitransparent so we can see the background through it. We’ll make it fully transparent eventually, but for now, we still want to see its position relative to the background.

Firefox screenshot. Shows a blurred mono (bluish in this case) text and emoji element below everything else. 'Everything else' in this case is another text and emoji element that uses a semitransparent color so we can partly see through to the background which is set to the blurred element via the element() function.
Firefox screenshot of the result (live demo) when using the blurred element #blur as a background.

We can notice a one issue right away: while margin works to offset the actual #blur element, it does nothing for shifting its position as a background. In order to get such an effect, we need to use the transform property. This can also help us if we want a rotation or any other transform — as it can be seen below where we’ve replaced the margin with transform: rotate(-9deg).

Firefox screenshot. Shows a slightly rotated blurred mono (bluish in this case) text and emoji element below everything else. 'Everything else' in this case is another text and emoji element that uses a semitransparent color so we can partly see through to the background which is set to the slightly rotated blurred element via the element() function.
Firefox screenshot of the result (live demo) when using transform: rotate() instead of margin on the #blur element.

Alright, but we’re still sticking to just a translation for now:

#blur {
  /* same as before */
  transform: translate(-.25em, -.25em); /* replaced margin */
}
Firefox screenshot. Shows a slightly offset blurred mono (bluish in this case) text and emoji element below everything else. 'Everything else' in this case is another text and emoji element that uses a semitransparent color so we can partly see through to the background which is set to the slightly offset blurred element via the element() function. This slight offset means the actual text doesn't perfectly overlap with the background one anymore.
Firefox screenshot of the result (live demo) when using transform: translate() instead of margin on the #blur element.

One thing to note here is that a bit of the blurred background gets cut off as it goes outside the limits of the middle layer’s padding-box. That doesn’t matter at this step anyway since our next move is to clip the background to the text area, but it’s good to just have that space since the .base layer is going to get translated just as far.

Firefox screenshot. Shows a slightly offset blurred mono (bluish in this case) text and emoji element below everything else. 'Everything else' in this case is another text and emoji element that uses a semitransparent color so we can partly see through to the background which is set to the slightly offset blurred element via the element() function. This slight offset means the actual text doesn't perfectly overlap with the background one anymore. It also means that the translated background text may not fully be within the limits of the padding-box anymore, as highlighted in this screenshot, which also shows the element boxes overlays.
Firefox screenshot highlighting how the translated #blur background exceeds the limits of the padding-box on the .midl element.

So, we’re going to bump up the padding by a little bit, even if, at this point, it makes absolutely no difference visually as we’re also setting background-clip: text on our .midl element.

article {
  /* same as before */
  padding: .5em;
}

#blur {
  position: absolute;
  bottom: 100vh;
  transform: translate(-.25em, -.25em);
  filter: sepia(1) hue-rotate(165deg) contrast(1.5) blur(5px);
}

.midl {
  --a: .1;
  background: -moz-element(#blur);
  background-clip: text;
}

We’ve also moved the #blur element out of sight and further reduced the alpha of the .midl element’s color, as we want a better view at the background through the text. We’re not making it fully transparent, but still keeping it visible for now just so we know what area it covers.

Firefox screenshot. Shows a text and emoji element that uses a semitransparent color so we can partly see through to the background which is set to a blurred element (now positioned out of sight) via the element() function. This slight offset means the actual text doesn't perfectly overlap with the background one anymore. We have also clipped the background of this element to the text, so that none of the background outside it is visible. Even so, there's enough padding room so that the blurred background is contained within the padding-box.
Firefox screenshot of the result (live demo) after clipping the .midl element’s background to text.

The next step is to add the .base element with pretty much the same styles as it had in the Chrome case, only replacing the margin with a transform.

<article id='blur' aria-hidden='true'>Lion 🦁</article>
<article class='base' aria-hidden='true'>Lion 🦁</article>
<article class='midl'>Lion 🦁</article>
#blur {
  position: absolute;
  bottom: 100vh;
  transform: translate(-.25em, -.25em);
  filter: sepia(1) hue-rotate(165deg) contrast(1.5) blur(5px);
}

.base {
  transform: translate(-.25em, -.25em);
  filter: sepia(1) hue-rotate(165deg) contrast(1.5);
}

Since a part of these styles are common, we can also add the .base class on our blurred element #blur in order to avoid duplication and reduce the amount of code we write.

<article id='blur' class='base' aria-hidden='true'>Lion 🦁</article>
<article class='base' aria-hidden='true'>Lion 🦁</article>
<article class='midl'>Lion 🦁</article>
#blur {
  --r: 5px;
  position: absolute;
  bottom: 100vh;
}

.base {
  transform: translate(-.25em, -.25em);
  filter: sepia(1) hue-rotate(165deg) contrast(1.5) blur(var(--r, 0));
}
Firefox screenshot. Shows two text and emoji layers slightly offset from one another. The .base one, first in the DOM order, is made mono with a filter and slightly offset to the top left with a transform. The .midl one, following it in DOM order, has semitransparent text so that we can see through to the text clipped background, which uses as a background image the blurred version of the mono, slightly offset .base layer. In spite of DOM order, the .base layer still shows up on top.
Firefox screenshot of the result (live demo) after adding the .base layer.

We have a different problem here. Since the .base layer has a transform, it’s now on top of the .midl layer in spite of DOM order. The simplest fix? Add z-index: 2 on the .midl element!

Firefox screenshot. Shows two text and emoji layers slightly offset from one another. The .base one, first in the DOM order, is made mono with a filter and slightly offset to the top left with a transform. The .midl one, following it in DOM order, has semitransparent text so that we can see through to the text clipped background, which uses as a background image the blurred version of the mono, slightly offset .base layer. Having explicitly set a z-index on the .midl layer, it now shows up on top of the .base one.
Firefox screenshot of the result (live demo) after fixing the layer order such that .base is underneath .midl.

We still have another, slightly more subtle problem: the .base element is still visible underneath the semitransparent parts of the blurred background we’ve set on the .midl element. We don’t want to see the sharp edges of the .base layer text underneath, but we are because blurring causes pixels close to the edge to become semitransparent.

Screenshot. Shows two lines of blue text with a red outline to highlight the boundaries of the actual text. The text on the second line is blurred and it can be seen how this causes us to have semitransparent blue pixels on both sides of the red outline - both outside and inside.
The blur effect around the edges.

Depending on what kind of background we have on the parent of our text layers, this is a problem that can be solved with a little or a lot of effort.

If we only have a solid background, the problem gets solved by setting the background-color on our .midl element to that same value. Fortunately, this happens to be our case, so we won’t go into discussing the other scenario. Maybe in another article.

.midl {
  /* same as before */
  background: -moz-element(#blur) #fff;
  background-clip: text;
}
Firefox screenshot. Shows two text and emoji layers slightly offset from one another. The .base one, first in the DOM order, is made mono with a filter and slightly offset to the top left with a transform. The .midl one, following it in DOM order, has semitransparent text so that we can see through to the text clipped background, which uses as a background image the blurred version of the mono, slightly offset .base layer. Having explicitly set a z-index on the .midl layer and having set a fully opaque background-color on it, the .base layer now lies underneath it and it isn't visible through any semitransparent parts in the text area because there aren't any more such parts.
Firefox screenshot of the result (live demo) after ensuring the .base layer isn’t visible through the background of the .midl one.

We’re getting close to a nice result in Firefox! All that’s left to do is add the top .grey layer with the exact same styles as in the Chrome version!

.grey { filter: grayscale(1) opacity(.25); }

Sadly, doing this doesn’t produce the result we want, which is something that’s really obvious if we also make the middle layer text fully transparent (by zeroing its alpha --a: 0) so that we only see its background (which uses the blurred element #blur on top of solid white) clipped to the text area:

Firefox screenshot. Shows two text and emoji layers slightly offset from one another. The .base one, first in the DOM order, is made mono with a filter and slightly offset to the top left with a transform. The .midl one, following it in DOM order, has transparent text so that we can see through to the text clipped background, which uses as a background image the blurred version of the mono, slightly offset .base layer. Since the background-color of this layer coincides to that of their parent, it is hard to see. We also have a third .grey layer, the last in DOM order. This should be right on top of the .midl one, but, due to having set a z-index on the .midl layer, the .grey layer is underneath it and not visible, in spite of the DOM order.
Firefox screenshot of the result (live demo) after adding the top .grey layer.

The problem is we cannot see the .grey layer! Due to setting z-index: 2 on it, the middle layer .midl is now above what should be the top layer (the .grey one), in spite of the DOM order. The fix? Set z-index: 3 on the .grey layer!

.grey {
  z-index: 3;
  filter: grayscale(1) opacity(.25);
}

I’m not really fond of giving out z-index layer after layer, but hey, it’s low effort and it works! We now have a nice Firefox solution:

Firefox screenshot. Shows two text and emoji layers partly overlapping. The bottom one is mono (bluish in this case) and blurred at the intersection with the semitransparent grey one on top.
Firefox screenshot of the result (live demo) we were after.

Combining our solutions into a cross-browser one

We start with the Firefox code because there’s just more of it:

<article id='blur' class='base' aria-hidden='true'>Lion 🦁</article>
<article class='base' aria-hidden='true'>Lion 🦁</article>
<article class='midl' aria-hidden='true'>Lion 🦁</article>
<article class='grey'>Lion 🦁</article>
body { display: grid; }

article {
  grid-area: 1/ 1;
  place-self: center;
  padding: .5em;
  color: hsla(25, 100%, 55%, var(--a, 1));
  font: 900 21vw/ 1.25 cursive;
}

#blur {
  --r: 5px;
  position: absolute;
  bottom: 100vh;
}

.base {
  transform: translate(-.25em, -.25em);
  filter: sepia(1) hue-rotate(165deg) contrast(1.5) blur(var(--r, 0));
}

.midl {
  --a: 0;
  z-index: 2;
  background: -moz-element(#blur) #fff;
  background-clip: text;
}

.grey {
  z-index: 3;
  filter: grayscale(1) opacity(.25);
}

The extra z-index declarations don’t impact the result in Chrome and neither does the out-of-sight #blur element. The only things that this is missing in order for this to work in Chrome are the backdrop-filter and the mask declarations on the .midl element:

backdrop-filter: blur(5px);
-webkit-mask: linear-gradient(red, red) text;

Since we don’t want the backdrop-filter to get applied in Firefox, nor do we want the background to get applied in Chrome, we use @supports:

$r: 5px;

/* same as before */

#blur {
  /* same as before */
  --r: #{$r};
}

.midl {
  --a: 0;
  z-index: 2;
  /* need to reset inside @supports so it doesn't get applied in Firefox */
  backdrop-filter: blur($r);
  /* invalid value in Firefox, not applied anyway, no need to reset */
  -webkit-mask: linear-gradient(red, red) text;
  
  @supports (background: -moz-element(#blur)) { /* for Firefox */
    background: -moz-element(#blur) #fff;
    background-clip: text;
    backdrop-filter: none;
  }
}

This gives us a cross-browser solution!

Chrome (top) and Firefox (bottom) screenshot collage of the text and emoji glassmorphism effect for comparison. The blurred backdrop seems thicker in Chrome and the emojis are obviously different, but the result is otherwise pretty similar.
Chrome (top) and Firefox (bottom) screenshots of the result (live demo) we were after.

While the result isn’t the same in the two browsers, it’s still pretty similar and good enough for me.

What about one-elementing our solution?

Sadly, that’s impossible.

First off, the Firefox solution requires us to have at least two elements since we use one (referenced by its id) as a background for another.

Second, while the first thought with the remaining three layers (which are the only ones we need for the Chrome solution anyway) is that one of them could be the actual element and the other two its pseudos, it’s not so simple in this particular case.

For the Chrome solution, each of the layers has at least one property that also irreversibly impacts any children and any pseudos it may have. For the .base and .grey layers, that’s the filter property. For the middle layer, that’s the mask property.

So while it’s not pretty to have all those elements, it looks like we don’t have a better solution if we want the glassmorphism effect to work on emojis too.

If we only want the glassmorphism effect on plain text — no emojis in the picture — this can be achieved with just two elements, out of which only one is needed for the Chrome solution. The other one is the #blur element, which we only need in Firefox.

<article id='blur'>Blood</article>
<article class='text' aria-hidden='true' data-text='Blood'></article>

We use the two pseudos of the .text element to create the base layer (with the ::before) and a combination of the other two layers (with the ::after). What helps us here is that, with emojis out of the picture, we don’t need filter: grayscale(1), but instead we can control the saturation component of the color value.

These two pseudos are stacked one on top of the other, with the bottom one (::before) offset by the same amount and having the same color as the #blur element. This color value depends on a flag, --f, that helps us control both the saturation and the alpha. For both the #blur element and the ::before pseudo (--f: 1), the saturation is 100% and the alpha is 1. For the ::after pseudo (--f: 0), the saturation is 0% and the alpha is .25.

$r: 5px;

%text { // used by #blur and both .text pseudos
  --f: 1;
  grid-area: 1/ 1; // stack pseudos, ignored for absolutely positioned #base
  padding: .5em;
  color: hsla(345, calc(var(--f)*100%), 55%, calc(.25 + .75*var(--f)));
  content: attr(data-text);
}

article { font: 900 21vw/ 1.25 cursive }

#blur {
  position: absolute;
  bottom: 100vh;
  filter: blur($r);
}

#blur, .text::before {
  transform: translate(-.125em, -.125em);
  @extend %text;
}

.text {
  display: grid;
	
  &::after {
    --f: 0;
    @extend %text;
    z-index: 2;
    backdrop-filter: blur($r);
    -webkit-mask: linear-gradient(red, red) text;

    @supports (background: -moz-element(#blur)) {
      background: -moz-element(#blur) #fff;
      background-clip: text;
      backdrop-filter: none;
    }
  }
}

Applying the cross-browser solution to our use case

The good news here is our particular use case where we only have the glassmorphism effect on the link icon (not on the entire link including the text) actually simplifies things a tiny little bit.

We use the following Pug to generate the structure:

- let data = {
-   home: { ico: '🏠', hue: 200 }, 
-   notes: { ico: '🗒️', hue: 260 }, 
-   activity: { ico: '🔔', hue: 320 }, 
-   discovery: { ico: '🧭', hue: 30 }
- };
- let e = Object.entries(data);
- let n = e.length;

nav
  - for(let i = 0; i < n; i++)
    - let ico = e[i][1].ico;
    a.item(href='#' style=`--hue: ${e[i][1].hue}deg`)
      span.icon.tint(id=`blur${i}` aria-hidden='true') #{ico}
      span.icon.tint(aria-hidden='true') #{ico}
      span.icon.midl(aria-hidden='true' style=`background-image: -moz-element(#blur${i})`) #{ico}
      span.icon.grey(aria-hidden='true') #{ico}
      | #{e[i][0]}

Which produces an HTML structure like the one below:

<nav>
  <a class='item' href='#' style='--hue: 200deg'>
    <span class='icon tint' id='blur0' aria-hidden='true'>🏠</span>
    <span class='icon tint' aria-hidden='true'>🏠</span>
    <span class='icon midl' aria-hidden='true' style='background-image: -moz-element(#blur0)'>🏠</span>
    <span class='icon grey' aria-hidden='true'>🏠</span>
    home
  </a>
  <!-- the other nav items -->
</nav>

We could probably replace a part of those spans with pseudos, but I feel it’s more consistent and easier like this, so a span sandwich it is!

One very important thing to notice is that we have a different blurred icon layer for each of the items (because each and every item has its own icon), so we set the background of the .midl element to it in the style attribute. Doing things this way allows us to avoid making any changes to the CSS file if we add or remove entries from the data object (thus changing the number of menu items).

We have almost the same layout and prettified styles we had when we first CSS-ed the nav bar. The only difference is that now we don’t have pseudos in the top cell of an item’s grid; we have the spans:

span {
  grid-area: 1/ 1; /* stack all emojis on top of one another */
  font-size: 4em; /* bump up emoji size */
}

For the emoji icon layers themselves, we also don’t need to make many changes from the cross-browser version we got a bit earlier, though there are a few lttle ones.

First off, we use the transform and filter chains we picked initially when we were using the link pseudos instead of spans. We also don’t need the color: hsla() declaration on the span layers any more since, given that we only have emojis here, it’s only the alpha channel that matters. The default, which is preserved for the .base and .grey layers, is 1. So, instead of setting a color value where only the alpha, --a, channel matters and we change that to 0 on the .midl layer, we directly set color: transparent there. We also only need to set the background-color on the .midl element in the Firefox case as we’ve already set the background-image in the style attribute. This leads to the following adaptation of the solution:

.base { /* mono emoji version */
  transform: translate(.375em, -.25em) rotate(22.5deg);
  filter: sepia(1) hue-rotate(var(--hue)) saturate(3) blur(var(--r, 0));
}

.midl { /* middle, transparent emoji version */
  color: transparent; /* so it's not visible */
  backdrop-filter: blur(5px);
  -webkit-mask: linear-gradient(red 0 0) text;
  
  @supports (background: -moz-element(#b)) {
    background-color: #fff;
    background-clip: text;
    backdrop-filter: none;
  }
}

And that’s it — we have a nice icon glassmorphism effect for this nav bar!

Chrome (top) and Firefox (bottom) screenshot collage of the emoji glassmorphism effect for comparison. The emojis are obviously different, but the result is otherwise pretty similar.
Chrome (top) and Firefox (bottom) screenshots of the desired emoji glassmorphism effect (live demo).

There’s just one more thing to take care of — we don’t want this effect at all times; only on :hover or :focus states. So, we’re going to use a flag, --hl, which is 0 in the normal state, and 1 in the :hover or :focus state in order to control the opacity and transform values of the .base spans. This is a technique I’ve detailed in an earlier article.

$t: .3s;

a {
  /* same as before */
  --hl: 0;
  color: hsl(var(--hue), calc(var(--hl)*100%), 65%);
  transition: color $t;
  
  &:hover, &:focus { --hl: 1; }
}

.base {
  transform: 
    translate(calc(var(--hl)*.375em), calc(var(--hl)*-.25em)) 
    rotate(calc(var(--hl)*22.5deg));
  opacity: var(--hl);
  transition: transform $t, opacity $t;
}

The result can be seen in the interactive demo below when the icons are hovered or focused.

What about using SVG icons?

I naturally asked myself this question after all it took to get the CSS emoji version working. Wouldn’t the plain SVG way make more sense than a span sandwich, and wouldn’t it be simpler? Well, while it does make more sense, especially since we don’t have emojis for everything, it’s sadly not less code and it’s not any simpler either.

But we’ll get into details about that in another article!


Icon Glassmorphism Effect in CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/icon-glassmorphism-effect-in-css/feed/ 6 322098
CSS-ing Candy Ghost Buttons https://css-tricks.com/css-ing-candy-ghost-buttons/ https://css-tricks.com/css-ing-candy-ghost-buttons/#comments Mon, 01 Nov 2021 01:04:53 +0000 https://css-tricks.com/?p=354804 Recently, while looking for some ideas on what to code as I have zero artistic sense so the only thing I can do is find pretty things that other people have come up with and remake them with clean and …


CSS-ing Candy Ghost Buttons originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
Recently, while looking for some ideas on what to code as I have zero artistic sense so the only thing I can do is find pretty things that other people have come up with and remake them with clean and compact code… I came across these candy ghost buttons!

They seemed like the perfect choice for a cool little thing I could quickly code. Less than fifteen minutes later, this was my Chromium result:

Chrome screenshot. Shows a four row, five column grid of candy ghost buttons with text and an icon following it. These buttons have an elongated pill-like shape, a transparent background and a continuous sweet pastel gradient for the border and the text and icon inside.
The pure CSS candy ghost buttons.

I thought the technique was worth sharing, so in this article, we’ll be going through how I first did it and what other options we have.

The starting point

A button is created with… are you ready for this? A button element! This button element has a data-ico attribute in which we drop an emoji. It also has a stop list custom property, --slist, set in the style attribute.

<button data-ico="👻" style="--slist: #ffda5f, #f9376b">boo!</button>

After writing the article, I learned that Safari has a host of problems with clipping to text, namely, it doesn’t work on button elements, or on elements with display: flex (and perhaps grid too?), not to mention the text of an element’s children. Sadly, this means all of the techniques presented here fail in Safari. The only workaround is to apply all the button styles from here on a span element nested inside the button, covering its parent’s border-box. And, in case this helps anyone else who, like me, is on Linux without physical access to an Apple device (unless you count the iPhone 5 someone on the fourth floor — who you don’t want to bother with stuff like this more than twice a month anyway — bought recently), I’ve also learned to use Epiphany in the future. Thanks to Brian for the suggestion!

For the CSS part, we add the icon in an ::after pseudo-element and use a grid layout on the button in order to have nice alignment for both the text and the icon. On the button, we also set a border, a padding, a border-radius, use the stop list, --slist, for a diagonal gradient and prettify the font.

button {
  display: grid;
  grid-auto-flow: column;
  grid-gap: .5em;
  border: solid .25em transparent;
  padding: 1em 1.5em;
  border-radius: 9em;
  background: 
    linear-gradient(to right bottom, var(--slist)) 
      border-box;
  font: 700 1.5em/ 1.25 ubuntu, sans-serif;
  text-transform: uppercase;
  
  &::after { content: attr(data-ico) }
}

There’s one thing to clarify about the code above. On the highlighted line, we set both the background-origin and the background-clip to border-box. background-origin both puts the 0 0 point for background-position in the top-left corner of the box it’s set to and gives us the box whose dimensions the background-size is relative to.

That is, if background-origin is set to padding-box, the 0 0 point for background-position is in the top left-corner of the padding-box. If background-origin is set to border-box, the 0 0 point for background-position is in the top-left corner of the border-box. If background-origin is set to padding-box, a background-size of 50% 25% means 50% of the padding-box width and 25% of the padding-box height. If background-origin is set to border-box, the same background-size of 50% 25% means 50% of the border-box width and 25% of the border-box height.

The default value for background-origin is padding-box, meaning that a default-sized 100% 100% gradient will cover the padding-box and then repeat itself underneath the border (where we cannot see it if the border is fully opaque). However, in our case, the border is fully transparent and we want our gradient to stretch across the entire border-box. This means we need to change the background-origin value to border-box.

Screenshot collage. Chrome on the left, Firefox on the right, showing differences between ghost emojis. The button has a pastel gradient background going along the main diagonal, the text 'Boo!' in black and a ghost emoji, which is going to look different depending on the OS and browser.
The result after applying the base styles (live demo).

The simple, but sadly non-standard Chromium solution

This involves using three mask layers and compositing them. If you need a refresher on mask compositing, you can check out this crash course.

Note that in the case of CSS mask layers, only the alpha channel matters, as every pixel of the masked element gets the alpha of the corresponding mask pixel, while the RGB channels don’t influence the result in any way, so they may be any valid value. Below, you can see the effect of a purple to transparent gradient overlay versus the effect of using the exact same gradient as a mask.

Screenshot. Shows two Halloween-themed cat pictures (the cat is protectively climbed on top of a Halloween pumpkin) side by side. The first one has a purple to transparent linear gradient overlay on top. The second one uses the exact same linear gradient as a mask. By default, CSS masks are alpha masks, meaning that every pixel of the masked element gets the alpha of the corresponding mask pixel.
Gradient overlay vs. the same gradient mask (live demo).

We’re going to start with the bottom two layers. The first one is a fully opaque layer, fully covering the entire border-box, meaning that it has an alpha of 1 absolutely everywhere. The other one is also fully opaque, but restricted (by using mask-clip) to the padding-box, which means that, while this layer has an alpha of 1 all across the padding-box, it’s fully transparent in the border area, having an alpha of 0 there.

If you have a hard time picturing this, a good trick is to think of an element’s layout boxes as nested rectangles, the way they’re illustrated below.

Illustration showing the layout boxes. The outermost box is the border-box. Inside it, a border-width away from the border limit, we have the padding-box. And finally, inside the padding-box, a padding away from the padding limit, we have the content-box.
The layout boxes (live demo).

In our case, the bottom layer is fully opaque (the alpha value is 1) across the entire orange box (the border-box). The second layer, that we place on top of the first one, is fully opaque (the alpha value is 1) all across the red box (the padding-box) and fully transparent (with an alpha of 0) in the area between the padding limit and the border limit.

One really cool thing about the limits of these boxes is that corner rounding is determined by the border-radius (and, in the case of the padding-box, by the border-width as well). This is illustrated by the interactive demo below, where we can see how the corner rounding of the border-box is given by the border-radius value, while the corner rounding of the padding-box is computed as the border-radius minus the border-width (limited at 0 in case the difference is a negative value).

Now let’s get back to our mask layers, one being fully opaque all across the entire border-box, while the one on top of it is fully opaque across the padding-box area and fully transparent in the border area (between the padding limit and the border limit). These two layers get composited using the exclude operation (called xor in the non-standard WebKit version).

Illustration. Shows the bottom two background layers in 3D. The first one from the bottom has an alpha of 1 all across the entire border-box. The second one, layered on top of it, has an alpha of 1 across the padding box, within the padding limit; it also has an alpha of 0 in the border area, outside the padding limit, but inside the border limit.
The two base layers (live demo).

The name of this operation is pretty suggestive in the situation where the alphas of the two layers are either 0 or 1, as they are in our case — the alpha of the first layer is 1 everywhere, while the alpha of the second layer (that we place on top of the first) is 1 inside the padding-box and 0 in the border area between the padding limit and the border limit.

In this situation, it’s pretty intuitive that the rules of boolean logic apply — XOR-ing two identical values gives us 0, while XOR-ing two different ones gives us 1.

All across the padding-box, both the first layer and the second one have an alpha of 1, so compositing them using this operation gives us an alpha of 0 for the resulting layer in this area. However, in the border area (outside the padding limit, but inside the border limit), the first layer has an alpha of 1, while the second one has an alpha of 0, so we get an alpha of 1 for the resulting layer in this area.

This is illustrated by the interactive demo below, where you can switch between viewing the two mask layers separated in 3D and viewing them stacked and composited using this operation.

Putting things into code, we have:

button {
  /* same base styles */
  --full: linear-gradient(red 0 0);
  -webkit-mask: var(--full) padding-box, var(--full);
  -webkit-mask-composite: xor;
  mask: var(--full) padding-box exclude, var(--full);
}

Before we move further, let’s discuss a few fine-tuning details about the CSS above.

First off, since the fully opaque layers may be anything (the alpha channel is fixed, always 1 and the RGB channels don’t mater), I usually make them red — only three characters! In the same vein, using a conic gradient instead of a linear one would also save us one character, but I rarely ever do that since we still have mobile browsers that support masking, but not conic gradients. Using a linear one ensures we have support all across the board. Well, save for IE and pre-Chromium Edge but, then again, not much cool shiny stuff works in those anyway.

Second, we’re using gradients for both layers. We’re not using a plain background-color for the bottom one because we cannot set a separate background-clip for the background-color itself. If we were to have the background-image layer clipped to the padding-box, then this background-clip value would also apply to the background-color underneath — it would be clipped to the padding-box too and we’d have no way to make it cover the entire border-box.

Third, we’re not explicitly setting a mask-clip value for the bottom layer since the default for this property is precisely the value we want in this case, border-box.

Fourth, we can include the standard mask-composite (supported by Firefox) in the mask shorthand, but not the non-standard one (supported by WebKit browsers).

And finally, we always set the standard version last so it overrides any non-standard version that may also be supported.

The result of our code so far (still cross-browser at this point) looks like below. We’ve also added a background-image on the root so that it’s obvious we have real transparency across the padding-box area.

Screenshot. The pastel gradient button is just a shadow of its former self. Well, just a border, that's all we can see of it. The entire area inside the padding limit has been masked out and we can now see through to the image background behind the button.
The result after masking out the entire padding-box (live demo).

This is not what we want. While we have a nice gradient border (and by the way, this is my preferred method of getting a gradient border since we have real transparency all across the padding-box and not just a cover), we are now missing the text.

So the next step is to add back the text using yet another mask layer on top of the previous ones, this time one that’s restricted to text (while also making the actual text fully transparent so that we can see the gradient background through it) and XOR this third mask layer with the result of XOR-ing the first two (result that can be seen in the screenshot above).

The interactive demo below allows viewing the three mask layers both separated in 3D as well as stacked and composited.

Note that the text value for mask-clip is non-standard, so, sadly, this only works in Chrome. In Firefox, we just don’t get any masking on the button anymore and having made the text transparent, we don’t even get graceful degradation.

button {
  /* same base styles */
  -webkit-text-fill-color: transparent;
  --full: linear-gradient(red 0 0);
  -webkit-mask: var(--full) text, var(--full) padding-box, var(--full);
  -webkit-mask-composite: xor;
  /* sadly, still same result as before :( */
  mask: var(--full) padding-box exclude, var(--full);
}

If we don’t want to make our button unusable this way, we should put the code that applies the mask and makes the text transparent in a @supports block.

button {
  /* same base styles */

  @supports (-webkit-mask-clip: text) {
    -webkit-text-fill-color: transparent;
    --full: linear-gradient(red 0 0);
    -webkit-mask: var(--full) text, var(--full) padding-box, var(--full);
    -webkit-mask-composite: xor;
  }
}
Screenshot collage. Chrome (left) vs. Firefox (right). In Chrome, we have a real pill-shaped pastel gradient ghost button. It has a transparent background that lets us see through to the image background behind our button and a continuous sweet pastel gradient for the border and the text and icon inside. In Firefox, we have the same pill-shaped, pastel background, black text and normal emoji button we had after setting the base styles. The ghost emoji is going to look different depending on the OS and browser - here it can be seen it has different looks in Chrome and Firefox.
The final result using the masking-only method (live demo).

I really like this method, it’s the simplest we have at this point and I’d really wish text was a standard value for mask-clip and all browsers supported it properly.

However, we also have a few other methods of achieving the candy ghost button effect, and although they’re either more convoluted or more limited than the non-standard Chromium-only one we’ve just discussed, they’re also better supported. So let’s take a look at those.

The extra pseudo-element solution

This involves setting the same initial styles as before, but, instead of using a mask, we clip the background to the text area.

button {
  /* same base styles */
  background: 
    linear-gradient(to right bottom, var(--slist)) 
    border-box;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent
}

Just like before, we need to also make the actual text transparent, so we can see through it to the pastel gradient background behind it that is now clipped to its shape.

Screenshot collage. Chrome (left) vs. Firefox (right), highlighting the differences in emoji shapes when they're part of knockout text. This is entirely normal and fine, as emojis look different depending on OS and browser.
Knockout button text (live demo).

Alright, we have the gradient text, but now we’re missing the gradient border. So we’re going to add it using an absolutely positioned ::before pseudo-element that covers the entire border-box area of the button and inherits the border, border-radius and background from its parent (save for the background-clip, which gets reset to border-box).

$b: .25em;

button {
  /* same as before */
  position: relative;
  border: solid $b transparent;
  
  &::before { 
    position: absolute;
    z-index: -1;
    inset: -$b;
    border: inherit;
    border-radius: inherit;
    background: inherit;
    background-clip: border-box;
    content: '';
  }
}

inset: -$b is a shorthand for:

top: -$b;
right: -$b;
bottom: -$b;
left: -$b

Note that we’re using the border-width value ($b) with a minus sign here. The 0 value would make the margin-box of the pseudo (identical to the border-box in this case since we have no margin on the ::before) only cover the padding-box of its button parent and we want it to cover the entire border-box. Also, the positive direction is inwards, but we need to go outwards by a border-width to get from the padding limit to the border limit, hence the minus sign — we’re going in the negative direction.

We’ve also set a negative z-index on this absolutely positioned element since we don’t want it to be on top of the button text and prevent us from selecting it. At this point, text selection is the only way we can distinguish the text from the background, but we’ll soon fix that!

Screenshot. Shows how text selection is the only way of still distinguishing the transparent text and gradient background clipped to text area button from its gradient background ::before pseudo that covers it fully.
The result after adding the gradient pseudo (live demo).

Note that since pseudo-element content isn’t selectable, the selection only includes the button’s actual text content and not the emoji in the ::after pseudo-element as well.

The next step is to add a two layer mask with an exclude compositing operation between them in order to leave just the border area of this pseudo-element visible.

button {
  /* same as before */
    
  &::before { 
    /* same as before */
    --full: linear-gradient(red 0 0);
    -webkit-mask: var(--full) padding-box, var(--full);
    -webkit-mask-composite: xor;
    mask: var(--full) padding-box exclude, var(--full);
  }
}

This is pretty much what we did for the actual button in one of the intermediate stages of the previous method.

Screenshot collage. Chrome (left) vs. Firefox (right). Both display a pill-shaped pastel gradient ghost button. The only difference is in the shape of the emoji. This is entirely normal and fine, as emojis look different depending on OS and browser.
The final result using the extra pseudo method (live demo).

I find this to be the best approach in most cases when we want something cross-browser and that doesn’t include IE or pre-Chromium Edge, none of which ever supported masking.

The border-image solution

At this point, some of you may be screaming at the screen that there’s no need to use the ::before pseudo-element when we could use a gradient border-image to create this sort of a ghost button — it’s a tactic that has worked for over three quarters of a decade!

However, there’s a very big problem with using border-image for pill-shaped buttons: this property doesn’t play nice with border-radius, as it can be seen in the interactive demo below. As soon as we set a border-image on an element with border-radius, we lose the corner rounding of the border, even through, funny enough, the background will still respect this rounding.

Still, this may be a simple solution in the case where don’t need corner rounding or the desired corner rounding is at most the size of the border-width.

In the no corner rounding case, save for dropping the now pointless border-radius, we don’t need to change the initial styles much:

button {
  /* same base styles */
  --img: linear-gradient(to right bottom, var(--slist));
  border: solid .25em;
  border-image: var(--img) 1;
  background: var(--img) border-box;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

The result can be seen below, cross-browser (should be supported supported even in pre-Chromium Edge).

Screenshot collage. Chrome (left) vs. Firefox (right). Both display a pastel gradient ghost button with no rounded corners. The only difference is in the shape of the emoji. This is entirely normal and fine, as emojis look different depending on OS and browser.
The no corner rounding result using the border-image method (live demo).

The trick with the desired corner rounding being smaller than the border-width relies on the way border-radius works. When we set this property, the radius we set represents the rounding for the corners of the border-box. The rounding for the corners of the padding-box (which is the inner rounding of the border) is the border-radius minus the border-width if this difference is positive and 0 (no rounding) otherwise. This means we have no inner rounding for the border if the border-radius is smaller than or equal to the border-width.

In this situation, we can use the inset() function as a clip-path value since it also offers the possibility of rounding the corners of the clipping rectangle. If you need a refresher on the basics of this function, you can check out the illustration below:

Illustration of how inset(d round r) works. Shows the clipping rectangle inside the element's border-box, its edges all a distance d away from the border limit. The corners of this clipping rectangle all have a rounding r along both axes.
How the inset() function works.

inset() cuts out everything outside a clipping rectangle defined by the distances to the edges of the element’s border-box, specified the same way we’d specify margin, border or padding (with one, two, three or four values) and the corner rounding for this rectangle, specified the same way we’d specify border-radius (any valid border-radius value is also valid here).

In our case, the distances to the edges of the border-box are all 0 (we don’t want to chop anything off any of the edges of the button), but we have a rounding that has to be at most at big as the border-width so that not having any inner border rounding makes sense.

$b: .25em;

button {
  /* same as before */
  border: solid $b transparent;
  clip-path: inset(0 round $b)
}

Note that the clip-path is also going to cut out any outer shadows we may add on the button element, whether they’re added via box-shadow or filter: drop-shadow().

Screenshot collage. Chrome (left) vs. Firefox (right). Both display a pastel gradient ghost button with small rounded corners, the rounding radius being the same size as the border-width. The only difference is in the shape of the emoji. This is entirely normal and fine, as emojis look different depending on OS and browser.
The small corner rounding result using the border-image method (live demo).

While this technique cannot achieve the pill shape look, it does have the advantage of having great support nowadays and it may be all we need in certain situations.

The three solutions discussed so far can be seen compiled in the demo below, which also comes with a YouTube link where you can see me code each of them from scratch if you prefer to learn by watching things being built on video rather than reading about them.

All these methods create real transparency in the padding-box outside the text, so they work for any background we may have behind the button. However, we also have a couple of other methods that may be worth mentioning, even though they come with restrictions in this department.

The cover solution

Just like the border-image approach, this is a pretty limited tactic. It doesn’t work unless we have a solid or a fixed background behind the button.

It involves layering backgrounds with different background-clip values, just like the cover technique for gradient borders. The only difference is that here we add one more gradient layer on top of the one emulating the background behind our button element and we clip this top layer to text.

$c: #393939;

html { background: $c; } 

button {
  /* same as before */
  --grad: linear-gradient(to right bottom, var(--slist));
  border: solid .25em transparent;
  border-radius: 9em;
  background: var(--grad) border-box, 
              linear-gradient($c 0 0) /* emulate bg behind button */, 
              var(--grad) border-box;
  -webkit-background-clip: text, padding-box, border-box;
  -webkit-text-fill-color: transparent;
}

Sadly, this approach fails in Firefox due to an old bug — just not applying any background-clip while also making the text transparent produces a pill-shaped button with no visible text.

Screenshot collage. Chrome (left) vs. Firefox (right). Chrome displays a pill-shaped pastel gradient ghost button. Firefox sadly only displays a pill-shaped button with no visible text.
The all background-clip cover solution (live demo).

We could still make it cross-browser by using the cover method for the gradient border on a ::before pseudo and background-clip: text on the actual button, which is basically just a more limited version of the second solution we discussed — we still need to use a pseudo, but, since we use a cover, not a mask, it only works if we have a solid or fixed background behind the button.

$b: .25em;
$c: #393939;

html { background: $c; } 

button {
  /* same base styles */
  --grad: linear-gradient(to right bottom, var(--slist));
  border: solid $b transparent;
  background: var(--grad) border-box;
  -webkit-background-clip: text;
          background-clip: text;
  -webkit-text-fill-color: transparent;
  
  &::before {
    position: absolute;
    z-index: -1;
    inset: -$b;
    border: inherit;
    border-radius: inherit;
    background: linear-gradient($c 0 0) padding-box, 
                var(--grad) border-box;
    content: '';
  }
}

On the bright side, this more limited version should also work in pre-Chromium Edge.

Screenshot collage. Chrome (left) vs. Firefox (right). Both display a pill-shaped pastel gradient ghost button that has a solid background behind. The only difference is in the shape of the emoji. This is entirely normal and fine, as emojis look different depending on OS and browser.
The cover solution on a pseudo for a solid background behind the button (live demo).

Below, there’s also the fixed background version.

$f: url(balls.jpg) 50%/ cover fixed;

html { background: $f; } 

button {
  /* same as before */
  
  &::before {
    /* same as before */
    background: $f padding-box, 
                var(--grad) border-box
  }
}
Screenshot collage. Chrome (left) vs. Firefox (right). Both display a pill-shaped pastel gradient ghost button that has a fixed image background behind. The only difference is in the shape of the emoji. This is entirely normal and fine, as emojis look different depending on OS and browser.
The cover solution on a pseudo for a fixed background behind the button (live demo).

Overall, I don’t think this is the best tactic unless we both fit into the background limitation and we need to reproduce the effect in browsers that don’t support masking, but support clipping the background to the text, such as pre-Chromium Edge.

The blending solution

This approach is another limited one as it won’t work unless, for each and every gradient pixel that’s visible, its channels have values that are either all bigger or all smaller than than the corresponding pixel of the background underneath the button. However, this is not the worst limitation to have as it should probably lead to our page having better contrast.

Here, we start by making the parts where we want to have the gradient (i.e. the text, icon and border) either white or black, depending on whether we have a dark theme with a light gradient or a light theme with a dark gradient, respectively. The rest of the button (the area around the text and icon, but inside the border) is the inverse of the previously chosen color (white if we set the color value to black and black otherwise).

In our case, we have a pretty light gradient button on a dark background, so we start with white for the text, icon and border, and black for the background. The hex channel values of our two gradient stops are ff (R), da (G), 5f (B) and f9 (R), 37 (G), 6b (B), so we’d be safe with any background pixels whose channel values are at most as big as min(ff, f9) = f9 for red, min(da, 37) = 37 for green and min(5f, 6b) = 5f for blue.

This means having a background-color behind our button with channel values that are smaller or equal to f9, 37 and 5f, either on its own as a solid background, or underneath a background-image layer we blend with using the multiply blend mode (which always produces a result that’s at least as dark as the darker of the two layers). We’re setting this background on a pseudo-element since blending with the actual body or the html doesn’t work in Chrome.

$b: .25em;

body::before {
  position: fixed;
  inset: 0;
  background: url(fog.jpg) 50%/ cover #f9375f;
  background-blend-mode: multiply;
  content: '';
}

button {
  /* same base styles */
  position: relative; /* so it shows on top of body::before */
  border: solid $b;
  background: #000;
  color: #fff;
  
  &::after {
    filter: brightness(0) invert(1);
    content: attr(data-ico);
  }
}

Note that making the icon fully white means making it first black with brightness(0) and then inverting this black with invert(1).

Screenshot collage. Chrome (left) vs. Firefox (right). Both show a pill-shaped black and white (white border, white text, white emoji and black everything in between) button on top of a dark image background. The only difference is in the shape of the emoji. This is entirely normal and fine, as emojis look different depending on OS and browser.
The black and white button (live demo).

We then add a gradient ::before pseudo-element, just like we did for the first cross-browser method.

button {
  /* same styles as before */
  position: relative;
  
  &::before {
    position: absolute;
    z-index: 2;
    inset: -$b;
    border-radius: inherit;
    background: linear-gradient(to right bottom, var(--slist);
    pointer-events: none;
    content: '';
  }
}

The only difference is that here, instead of giving it a negative z-index, we give it a positive z-index. That way it’s not just over the actual button, but also over the ::after pseudo and we set pointer-events to none in order to allow the mouse to interact with the actual button content underneath.

Screenshot. Shows a pill-shaped gradient button with no visible text on top of a dark image background.
The result after adding a gradient pseudo on top of the black and white button (live demo).

Now the next step is to keep the black parts of our button, but replace the white parts (i.e., the text, icon and border) with the gradient. We can do this with a darken blend mode, where the two layers are the black and white button with the ::after icon and the gradient pseudo on top of it.

For each of the RGB channels, this blend mode takes the values of the two layers and uses the darker (smaller) one for the result. Since everything is darker than white, the resulting layer uses the gradient pixel values in that area. Since black is darker than everything, the resulting layer is black everywhere the button is black.

button {
  /* same styles as before */
  
  &::before {
    /* same styles as before */
    mix-blend-mode: darken;
  }
}
Screenshot collage.  Chrome (left) vs. Firefox (right). Both show a pill-shaped black and pastel gradient (pastel gradient border, text, emoji and black everything in between) button on top of a dark image background. The only difference is in the shape of the emoji. This is entirely normal and fine, as emojis look different depending on OS and browser.
The “almost there” result (live demo).

Alright, but we’d only be done at this point if the background behind the button was pure black. Otherwise, in the case of a background whose every pixel is darker than the corresponding pixel of the gradient on our button, we can apply a second blend mode, this time lighten on the actual button (previously, we had darken on the ::before pseudo).

For each of the RGB channels, this blend mode takes the values of the two layers and uses the lighter (bigger) one for the result. Since anything is lighter than black, the resulting layer uses the background behind the button everywhere the button is black. And since a requirement is that every gradient pixel of the button is lighter than the corresponding pixel of the background behind it, the resulting layer uses the gradient pixel values in that area.

button {
  /* same styles as before */
  mix-blend-mode: lighten;
}
Screenshot collage. Chrome (left) vs. Firefox (right). Both show a pill-shaped pastel gradient ghost with a 'BOO!' text and a ghost emoji button on top of a dark image background. The only difference is in the shape of the emoji. This is entirely normal and fine, as emojis look different depending on OS and browser.
The light ghost button on top of a dark background (live demo).

For a dark gradient button on a light background, we need to switch up the blend modes. That is, use lighten on the ::before pseudo and darken on the button itself. And first of all, we need to ensure the background behind the button is light enough.

Let’s say our gradient is between #602749 and #b14623. The channel values of our gradient stops are 60 (R), 27 (G), 49 (B) and b1 (R), 46 (G), 23 (R), so the background behind the button needs to have channel values that are at least max(60, b1) = b1 for red, max(27, 46) = 46 for green and max(49, 23) = 49 for blue.

This means having a background-color on our button with channel values that are bigger or equal to b1, 46 and 49, either on its own as a solid background, or underneath a background-image layer, uses a screen blend mode (which always produces a result that’s at least as light as the lighter of the two layers).

We also need to make the button border, text and icon black, while setting its background to white:

$b: .25em;

section {
  background: url(fog.jpg) 50%/ cover #b14649;
  background-blend-mode: screen;
}

button {
  /* same as before */
  border: solid $b;
  background: #fff;
  color: #000;
  mix-blend-mode: darken;

  &::before {
    /* same as before */
    mix-blend-mode: lighten
  }
  
  &::after {
    filter: brightness(0);
    content: attr(data-ico);
  }
}

The icon in the ::after pseudo-element is made black by setting filter: brightness(0) on it.

Screenshot collage. Chrome (left) vs. Firefox (right). Both show a pill-shaped dark gradient ghost with a 'BOO!' text and a ghost emoji button on top of a light image background. The only difference is in the shape of the emoji. This is entirely normal and fine, as emojis look different depending on OS and browser.
The dark ghost button on top of a light background (live demo).

We also have the option of blending all the button layers as a part of its background, both for the light and dark theme, but, as mentioned before, Firefox just ignores any background-clip declaration where text is a part of a list of values and not the single value.

Well, that’s it! I hope you’re having (or had) a scary Halloween. Mine was definitely made horrific by all the bugs I got to discover… or rediscover, along with the reality that they haven’t been fixed in the meanwhile.


CSS-ing Candy Ghost Buttons originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/css-ing-candy-ghost-buttons/feed/ 5 354804