Fading Edges With mask-image: Soft Boundaries That Work on Any Background

Hard edges tell the reader nothing. A horizontal shelf that ends abruptly at the container edge looks complete; a paragraph clamped to three lines looks like a mistake; a hero image that meets a coloured section with a straight line looks unfinished. A soft fade communicates "there is more" or "this continues" and blends content into its surroundings. The problem is that the obvious implementation — a gradient overlay in the page colour — breaks the moment the background is an image, a gradient, or a dark theme. This page solves fades with mask-image, which fades the content itself, and shows how to animate the fade without JavaScript. It is part of Clip-Path & Mask Animations in the CSS-Only Micro-Interactions & Animations guide.

Why a mask instead of an overlay

An overlay approach puts a pseudo-element on top of the content with background: linear-gradient(transparent, white). On a white page it looks like a fade. On a light-grey card it leaves a white smear. On a dark theme it paints a white band across the content. On a photographic background it is obviously a stripe. Every surface needs its own overlay colour, and every theme switch risks a mismatch.

A mask works the other way round. mask-image takes an image — usually a gradient — and uses its alpha channel to set the element's own transparency pixel by pixel. Where the mask is opaque, the element shows; where it is transparent, the element disappears and whatever is behind it shows through. Nothing is painted on top, so there is no colour to match.

Overlay versus mask on a dark surface Left, content with a light gradient overlay leaves a pale band on a dark card. Right, content faded with a mask reveals the dark card behind it, with no band. Same content, same dark card, two techniques content overlay: pale band on dark content mask: card shows through

That property is why masks are the right tool on a site with light and dark themes. The same rule produces a correct fade on both, with no theme-specific overrides.

The complete implementation

The page below uses masks for three common fades: the trailing edge of a horizontal scroller, the bottom of a clamped text block that expands on demand, and the lower edge of a hero image blending into the page.

Live demoThree mask fades on a dark surface
The hero fades at the bottom and both sides, the shelf fades at its trailing edge as you scroll it, and the excerpt fades until opened.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mask fades</title>
<style>
  body { font: 15px/1.6 system-ui, sans-serif; margin: 1.5rem; background: #0f172a; color: #e2e8f0; }

  /* 1. Trailing-edge fade on a horizontal scroller.
     The mask is fixed to the scroller's box, so content scrolls under it. */
  .shelf {
    display: flex;
    gap: 0.75rem;
    overflow-x: auto;
    padding-bottom: 0.5rem;
    mask-image: linear-gradient(to right, #000 calc(100% - 4rem), transparent);
  }
  .shelf > * { flex: 0 0 9rem; height: 6rem; border-radius: 8px; background: #6366f1; }

  /* 2. Clamped text that fades out instead of ending mid-word.
     Opening the details element removes the clamp and the mask. */
  .excerpt p {
    max-height: 5lh;           /* five lines of the current line-height */
    overflow: hidden;
    margin: 0;
    mask-image: linear-gradient(to bottom, #000 60%, transparent);
  }
  .excerpt[open] p {
    max-height: none;
    mask-image: none;
  }
  .excerpt summary { cursor: pointer; color: #93c5fd; }

  /* 3. Hero image that dissolves into the page background. Two gradients
     composed with intersect fade the bottom AND both sides at once. */
  .hero {
    height: 12rem;
    background: linear-gradient(135deg, #f59e0b, #db2777 55%, #4c1d95);
    mask-image:
      linear-gradient(to bottom, #000 55%, transparent),
      linear-gradient(to right, transparent, #000 15%, #000 85%, transparent);
    mask-composite: intersect;
  }
</style>
</head>
<body>
  <div class="hero" role="img" aria-label="Sunset gradient"></div>
  <div class="shelf" tabindex="0" aria-label="Collections"><div></div><div></div><div></div><div></div><div></div></div>
  <details class="excerpt">
    <summary>Read the full description</summary>
    <p>The old coastal road was built in stages over three decades…</p>
  </details>
</body>
</html>

Three details matter. The mask colour is irrelevant — #000 is used by convention, but only alpha counts, so black and white produce identical masks. The lh unit sizes the clamp in lines of the element's own line height, which keeps the fade proportional if the text is zoomed. And the hero uses two mask layers with mask-composite: intersect, so a pixel is visible only where both gradients are opaque: faded at the bottom and at each side.

A caution on the excerpt: a <details> element exposes the full text to screen readers only when open in some engines, and always in others. Here the full text is in the DOM regardless; the mask and height only affect sighted users, who can open the disclosure. That is the correct division — masks must never be the only way content becomes unreadable.

The key technique: alpha, layers and composition

A mask is an ordinary CSS image in a layered property, and the longhands mirror background: mask-image, mask-size, mask-position, mask-repeat, mask-origin, mask-clip, mask-mode and mask-composite. Understanding the layers is what unlocks the more interesting fades.

Composing two mask layers A vertical gradient layer fading toward the bottom and a horizontal layer fading at both sides combine with mask-composite intersect into a single mask faded on three edges. Layer A ∩ layer B = visible area to bottom to right = intersect add is the default; intersect, subtract and exclude combine layers like set operations.

mask-composite has four values. add (the default) shows a pixel if any layer is opaque there. intersect shows it only where all layers are opaque, which is how multi-edge fades are built. subtract removes the upper layer's area from the lower one, useful for cut-outs. exclude shows areas covered by exactly one layer. With two gradients and the right operator, most fades a design calls for can be expressed without an image file.

mask-mode decides whether a mask image's alpha or its luminance is used. Gradients and PNGs default to alpha. SVG <mask> elements referenced with url() default to luminance, which is why an SVG mask drawn in black appears to hide everything — in luminance mode black means transparent. Setting mask-mode: alpha makes it behave like a gradient.

Fades that should not exist

Masks are easy to overuse, and a few fades actively hurt.

Fading text the reader needs to read. A fade across the last visible line of body copy is fine when a "read more" control sits right below it. A fade across text that cannot be expanded simply makes that text harder to read, and faded text has lower contrast than WCAG 1.4.3 allows. Only fade content whose full version is one interaction away.

Fading focus indicators. A focus ring on an element near a masked edge is masked too, because the mask applies to the whole element including its outline. Keyboard users then tab to a card whose focus ring is invisible. Either keep interactive elements out of the fade zone with padding, or move the mask to a wrapper that does not contain the focusable items' outlines.

Fading that hides the end state. A trailing fade on a scroller that is already scrolled to its end suggests more content that does not exist. Where that matters, remove the mask at the end of the scroll range — a scroll-driven animation on the mask's stop position can do it without script — or accept a subtle fade that reads as decoration rather than a promise.

Masks on large, frequently repainted elements. A mask on a full-page container that also has a running animation inside it forces the masked result to be recomposited each frame. Put masks on the smallest element that needs them.

Variation: an animated reveal with a registered property

Gradients cannot be animated directly, because an image has no interpolable value. The trick is to lift the moving part into a custom property and register it with @property so the browser knows its type and can interpolate it.

@property --reveal {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}

/* A wipe-in: the opaque region grows from the left edge. */
.wipe {
  mask-image: linear-gradient(
    to right,
    #000 var(--reveal),
    transparent calc(var(--reveal) + 15%)
  );
  animation: wipe-in 0.9s ease-out forwards;
}

@keyframes wipe-in {
  to { --reveal: 100%; }
}

@media (prefers-reduced-motion: reduce) {
  .wipe { animation: none; --reveal: 100%; }
}

Each frame, the browser interpolates --reveal from 0% to 100%, rebuilds the gradient with the new stop positions, and repaints the mask. The 15% soft edge travels with the reveal, giving a feathered wipe rather than a hard line. Because this repaints, keep it to a one-off entrance on a modest element; for large or repeated effects, animating mask-position over a fixed oversized gradient is cheaper, since the browser can move a pre-rasterised mask. The hard-edged cousin of this effect, using clip-path, is covered in Clip-Path Reveal Animations.

The reduced-motion branch sets the final state directly rather than merely removing the animation, which would otherwise leave the element masked at 0% — fully invisible. That mistake is common with entrance animations and is one reason the guidance in prefers-reduced-motion Recipes recommends always defining the resting state first.

Browser support

Unprefixed mask-image, mask-composite and the other mask longhands are supported in current versions of Chrome, Edge, Firefox and Safari; older WebKit and Blink versions needed the -webkit-mask-* prefix, which is still worth including before the unprefixed property if you support them. @property, used for the animated reveal, is supported in Chrome and Edge 85+, Firefox 128+ and Safari 16.4+; without it the custom property still works but snaps to its final value instead of animating. The lh unit is newer; declare a rem-based max-height first as a fallback.

FAQ

Why use mask-image instead of a gradient overlay? A gradient overlay paints a colour on top of the content, so it only looks right on a background that exactly matches the overlay colour. A mask makes the content itself transparent, so the fade works on any background, including images, gradients and both light and dark themes.

Can I animate a mask-image gradient? Not directly, because gradients do not interpolate as images. Put the moving part, such as a stop position, in a custom property registered with @property as a length or percentage, and animate or transition the property. The gradient then updates smoothly each frame.

Does a mask hide content from screen readers? No. Masking is purely visual. Text that is fully masked is still in the accessibility tree and still read aloud, so never use a mask to hide content that should be unavailable; use it only to fade content that is also reachable some other way, such as by scrolling.

Is mask-image expensive to render? A static mask adds a compositing step but is cheap on modern hardware. Animating mask-position or mask-size can often be handled by the compositor, while animating a gradient through a custom property repaints the mask each frame, so keep such animations short or confined to small elements.

Related articles

More pages in the same section.