Masking With a Matrix of Gradients

There’s something appealing about an image that appears to shatter into pieces and drift away. A common way to build that effect is to layer a grid of HTML elements on top of an image and animate their opacity — but there’s a cleaner approach that leans entirely on CSS masks and a growing CSS feature: @property.

The result is a single <img> element with no JavaScript and no SVG. It works because masks can hold multiple gradient layers, and those gradient layers can now be animated when their values are driven by registered custom properties. One catch: full support for @property currently exists only in Chromium browsers, so the effect is best experienced in Chrome, Edge, or Opera.

Masks Are Images

Masking is often confused with clipping, but the two are fundamentally different. A clip defines a path — everything outside it is simply removed from view. A mask, on the other hand, is an image. Its transparent areas let the element show through; its opaque areas keep it visible. In effect, a mask applies opacity selectively across an element.

Because masks are images, CSS gradients are perfectly suited for building them. And just like background-image, an element can stack multiple mask layers. Each layer is independent, and the composited result determines the element’s visibility.

Take a simple case: two mask layers covering an image, each half the width and full the height. Written with shorthand gradients, one layer covers the left side at 80% opacity, the other covers the right at 30%:

img {
  mask:
    linear-gradient(rgba(0,0,0,0.8) 0 0) left,  /* 1 */
    linear-gradient(rgba(0,0,0,0.5) 0 0) right; /* 2 */
  mask-size: 50% 100%;
  mask-repeat: no-repeat;
}

The color itself is irrelevant since the default mask-mode is alpha. Only the alpha channel matters, so any RGB value works. Two non-overlapping layers together cover the whole image, and each applies its own level of transparency.

We’re looking at two mask layers created with two linear gradients. The first gradient, left, has an alpha value of 0.8. The second gradient, right, has an alpha value of 0.5. The first gradient is more opaque meaning more of the image shows through. The second gradient is more transparent meaning more of the of background shows through.

Animating Gradient Alphas

CSS had no reliable way to animate a gradient’s values — gradients were treated as static images. @property changes that. It registers a custom property with a defined type, letting the browser interpolate it over time and, consequently, animate any gradient that references it.

Start with two properties, --c-0 and --c-1, both typed as numbers with an initial value of 1:

@property --c-0 {
   syntax: "<number>";
   initial-value: 1;
   inherits: false;
}
@property --c-1 {
   syntax: "<number>";
   initial-value: 1;
   inherits: false;
}

When those values are plugged into a mask’s gradient alpha channels, the default opaque state shows the whole image. Animating them to 0 makes each section fade to transparent:

/* Omitting the @property blocks above for brevity */

img {
  mask:
    linear-gradient(rgba(0,0,0,var(--c-0)) 0 0) left,  /* 1 */
    linear-gradient(rgba(0,0,0,var(--c-1)) 0 0) right; /* 2 */
  mask-size: 50% 100%;
  mask-repeat: no-repeat;
  transition: --c-0 0.5s, --c-1 0.3s 0.4s;
}

img:hover {
  --c-0:0;
  --c-1:0;
}

With a transition duration and delay applied per property, hovering the image fades the first gradient out, followed by the second — a basic sequential reveal.

Scaling to a Fragmentation Grid

Two gradients produce two tiles. A full fragmentation effect needs many more. The practical way to manage that complexity is SCSS, which can generate both the @property declarations and the mask gradient layers.

Start by defining the grid dimensions in two SCSS variables, $x and $y, for columns and rows. Each tile requires its own custom property, so loop over the grid to register them:

@for $i from 0 through ($x - 1) {
  @for $j from 0 through ($y - 1) {
    @property --c-#{$i}-#{$j} {
      syntax: "<number>";
      initial-value: 1;
      inherits: false;
    }
  }
}

Hovering collapses all of them to an alpha of 0:

img:hover {
  @for $i from 0 through ($x - 1) {
    @for $j from 0 through ($y - 1) {
      --c-#{$i}-#{$j}: 0;
    }
  }
}

Generating the gradients calls for a @mixin. Each mask layer is the same size, so one background-size-equivalent declaration works for all, built with calc() from the grid variables:

mask-size: calc(100%/#{$x}) calc(100%/#{$y})

The mixin also emits the transition property listing every registered custom property:

$all_t: append($all_t, --c-#{$i}-#{$j} transition($i,$j), comma);

Every tile gets its own transition duration and delay thanks to SCSS’s random() function, giving the staggered, shattering feel:

@function transition($i,$j) {
  @return $s*random()+s $s*random()+s;
}

All that remains is tuning $x and $y to control how fine or coarse the fragments become.

Variations in Timing, Gradients, and Overlap

The animation itself is configurable. Instead of the same duration and delay pattern for every property, the delay formula can be changed to produce different behaviors — edges collapsing first, corners going last, a randomized burst. Swapping the formula inside the transition function changes the entire look with no structural changes:

// Uncomment one to use it
@function transition($i,$j) {
  // @return (($s*($i+$j))/($x+$y))+s (($s*($i+$j))/($x+$y))+s; /* diagonal */
  // @return (($s*$i)/$x)+s (($s*$j)/$y)+s; /* left to right */
  // @return (($s*$j)/$y)+s (($s*$i)/$x)+s; /* top to bottom */
  // @return  ($s*random())+s (($s*$j)/$y)+s; /* top to bottom random */
  @return  ($s*random())+s (($s*$i)/$y)+s; /* left to right random */
  // @return  ($s*random())+s (($s*($i+$j))/($x+$y))+s; /* diagonal random */
  // @return ($s*random())+s ($s*random())+s; /* full random*/
}

It’s also possible to stop animating alpha and instead animate gradient color stops. Replacing the mask’s gradient with percentage-based stops means the transition targets the stop position from 100% down to 0%:

linear-gradient(white var(--c-#{$i}-#{$j}),transparent 0)

There’s no reason to stay with linear gradients either; radial gradients produce an entirely different effect, and the combinations multiply further.

A separate variable can adjust the mask layer size beyond 1 to create overlap between adjacent fragments:

calc(#{$o}*100%/#{$x}) calc(#{$o}*100%/#{$y})

That overlap can generate yet another family of effects, often with more visual depth, since adjacent layers fade at different times while briefly sharing space.

The classic trick comes down to pairing the right set of $x and $y values with the right transition formula and gradient style — those few parameters cover a surprisingly wide range of fragmentation aesthetics.