CSS-ing Candy Ghost Buttons

Avatar of Ana Tudor
Ana Tudor on

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.