The candy ghost button, decoded
Emoji-backed buttons with pastel gradient borders — sometimes called “candy ghost buttons” — are a striking UI pattern. The effect looks intricate at first glance, but the core trick is really about layering mask, background-clip, and a few pseudo-elements. Here's how the effect is built, along with the fallback paths when browser support gets in the way.
Setting the stage
The markup is minimal: a plain button element that carries an emoji in a data-ico attribute and a stop list in a custom property, --slist, declared inline via the style attribute. The CSS then does the heavy lifting.
<button style="--slist: #ffda5f, #f9376b">boo!</button>
For layout, the button is given a grid display and the icon is inserted through an ::after pseudo-element. The styling applies a border, padding, border-radius, a diagonal gradient built from the custom stop list, and a custom 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) }
}
One important detail here: both background-origin and background-clip need to be set to border-box. The default background-origin is padding-box, which means a full-size gradient would cover the padding area but leave the border region invisible — since the border itself is transparent, the gradient would be effectively clipped. Moving the origin to border-box makes the gradient stretch across the entire box, border included.

The Chromium-only route: layered masks
The fastest way to get the ghost effect is by stacking multiple mask layers with an exclude compositing operation. For masks, only the alpha channel matters: every pixel of a masked element gets the alpha of the corresponding mask pixel, while RGB channels never influence the result. A purple-to-transparent gradient that looks like a fade when used as an overlay acts entirely differently when used as a mask.

The first two mask layers are simple rectangles. One layer is fully opaque across the entire border-box; the second is fully opaque across the padding-box but transparent in the border area. Because the alpha values here are only 0 or 1, compositing these two layers with exclude (or xor in WebKit) behaves like boolean logic:
- Inside the
padding-box, both layers are1, so the result is0— the area becomes transparent. - In the border region, the bottom layer is
1, while the top layer is0, so the result is1— only the border ring shows.
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);
}
A few implementation cautions:
- Use a
linear-gradient(or justred) rather than aconic-gradientfor the opaque layers — some mobile browsers still support masks but not conic gradients. - Gradients are required for both layers because
background-clipcannot be applied independently to abackground-color. - The bottom mask layer can rely on the default
mask-clipvalue ofborder-box. - Put the standard
mask-composite(which Firefox supports) last in the shorthand so it overrides any non-standard WebKit variant.
That builds a clean gradient border with genuine transparency across the interior, but the button text is gone by this point. The fix is a third mask layer with mask-clip: text, which gets XORed with the earlier result while the text color is set to transparent. Sadly, mask-clip: text is a non-standard value and only works in Chromium browsers; in Firefox, no masking is applied at all, leaving a button whose text has vanished.
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 you want the button to remain usable until support improves, put the mask and text-transparency rules inside a @supports block so browsers that can't handle the effect just ignore the declaration entirely.
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;
}
}
Cross-browser fallback: an extra pseudo-element
A better-supported approach avoids mask-clip: text on the button and instead clips the background to the text area directly. The initial styling stays the same, and the text is once again made transparent so the gradient behind it shows through the glyph shapes. The border, however, needs to be supplied separately via an absolutely positioned ::before pseudo-element.
That pseudo-element covers the entire border-box of the button and inherits the parent's border, border-radius, and gradient background, but resets background-clip back to border-box. It's positioned with inset: -$b (where $b is the border width), extending outward by one border width to reach from the padding limit to the border limit — a 0 inset would merely cover the button's own padding area.

Because the ::before layer sits on top of the text, give it a negative z-index. Keep in mind that text selection will still only work on actual content — emoji inside an ::after pseudo won't be selectable.
Finally, mask off everything but the border ring using the same exclude compositing of two mask layers that was described earlier. When broad browser support matters and IE/pre-Chromium Edge aren't in scope, this is the version to reach for.

Gradient borders without pseudo-elements
An older idea for ghost buttons is to substitute a gradient border-image for all the layering above. It has worked for building similar effects for years, but the property has a serious constraint: border-image doesn't respect border-radius. The background will keep its rounded corners, but the border itself will snap back to sharp edges. If the design requires a pill shape, it simply won't work.
In cases without corner rounding, the gradient border-image just works, no pseudo-elements or masks required:
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;
}
There is one clever loophole when the intended rounding is tiny — no larger than the border-width. The border-radius property rounds the corners of the border-box. The inner rounding of the border (the corners of the padding-box) is border-radius minus border-width; when that difference is 0 or negative, there is no inward curve to worry about. Under those conditions, a clip-path: inset() can add small rounded corners to a gradient-bordered button without running into the border-image limitation.
One tradeoff: any outer shadow applied to the button — whether via box-shadow or filter: drop-shadow() — becomes clipped away by that clip-path.
Cheaper tricks with limitations
The methods above all put true transparency inside the padding-box, so whatever sits behind the button, the look stays intact. Two remaining techniques are cheaper but come with strings attached.
Background cover
This tactic layers backgrounds with different background-clip values, wrapping the main gradient border effect in a “cover” layer. The twist is that one extra gradient layer gets a background-clip: text on top. Critically, it assumes that the page behind the button is either a solid color or a fixed background image — the cover layer needs to replicate what's behind the button in order to fake transparency.
$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;
}
Firefox, however, breaks the pure version of this trick: an old bug means setting background-clip: text alongside making the text transparent yields an empty pill, no text visible.
A cross-browser variant exists, switching to a cover-based border on a ::before pseudo and clipping the actual button's background to text. It is a more limited version of the pseudo-element solution above. On the upside, it does extend support to pre-Chromium Edge.
Blending
Where the background behind the button can be strictly controlled, blend modes offer an unusual route. The trick is to color the button's text, border, and emoji either pure white or pure black, and then do the same for the interior, using the inverse color:
- For a light button over a dark background, set text and border to
white, and the button'sbackgroundtoblack. Layer a filled gradient over the button with a positivez-index. Applying that gradient with adarkenblend mode replaces only the white pixels with the gradient, since white is always lighter, while black pixels stay black.
button {
/* same styles as before */
&::before {
/* same styles as before */
mix-blend-mode: darken;
}
}
If the background is not pure black but is still darker than every gradient pixel, add a lighten blend mode to the button itself. That lets the actual page background bleed through where the button is black, without disturbing the revealed gradient.
- For a dark gradient on a light background, swap the colors and blend modes
screen/lightenfor the contrasting case, usingdarkenon the button to let the background show through the dark text and border.
Making the emoji pure white or black takes a bit of CSS filter work. For the light theme's white icon, the emoji must first be forced black with brightness(0) and then inverted back to white with invert(1). When contrasting with the solid background, you also need a pseudo-element for the background layer — Chrome won't blend against a page-wide html/body background — and the gradient overlay needs pointer-events: none.
Each method gets progressively narrower in scope. For a design that must work everywhere and tolerate any background graphic, the extra pseudo-element approach is the most robust. When you own the background layer behind the button, blending is a legitimate alternative; when tight support constraints or tiny borders apply, border-image and the inset() rounding workaround just might save the day.



