The CSS Paint API and image fragmentation

An earlier approach to CSS-only image fragmentation leaned on generated mask layers and a thick pile of Sass. With the CSS Paint API, the same effect collapses to a handful of declarations and one JavaScript worklet. The API is part of the Houdini project and is currently supported in Chrome and Edge.

What the Paint API actually is

The specification describes it as a way for developers to define a custom CSS <image> with JavaScript that responds to style and size changes. The explainer puts it more plainly: developers can write a paint function that draws directly into an element's background, border, or content.

Working with the Paint API follows a repeatable pattern:

  1. Register the worklet with CSS.paintWorklet.addModule('your_js_file').
  2. Register a new paint method called draw.
  3. Inside that, create a paint() function where the actual drawing happens. The context is the familiar 2D canvas context, so standard canvas functions apply.

Custom properties enter the picture through the inputProperties getter, which returns an array of property names. The paint() function receives these via a third parameter, and values are retrieved with properties.get().

Building the mask with rectangles

Instead of stacking many gradient-based masks, the Paint API lets us draw a single custom image that acts as the mask. For a basic split, one half gets an opaque fill and the other a semi-transparent one; applying that as mask produces the expected partial transparency.

The fragmentation needs a grid of rectangles. Two CSS variables define the matrix dimensions:

const n = properties.get('--f-n');
const m = properties.get('--f-m');

const w = size.width/n;
const h = size.height/m;

for(var i=0;i<n;i++) {
  for(var j=0;j<m;j++) {
    ctx.fillStyle = 'rgba(0,0,0,'+(Math.random())+')';    
    ctx.fillRect(i*w, j*h, w, h);
}
}

N and M set the rows and columns. W and H are the computed width and height of each rectangle. A simple loop fills each one with a random transparent color.

But fading all rectangles together looks flat. The trick is staggering the transitions. The alpha animation for each rectangle runs between values X and Y, where X - Y = L and L is at least 1:

Alpha values outside the [0 1] range clamp to the nearest valid value, which is the key. Animating from, say, 8 to -2 means the visible change from fully opaque to fully transparent happens within the middle of that range — and not at the same time for every rectangle.

The original calculation for the alpha channel:

rgba(0,0,0,'+(o)+')

is replaced with:

rgba(0,0,0,'+((Math.random()*(l-1) + 1) - (1-o)*l)+')

With O=1, the expression gives a value in the [L 1] range. With O=0, it lands in [0 1-L]. The delay between rectangles is controlled by L.

There is one problem: the built-in Math.random() regenerates a new value on every paint() call. Since that function runs repeatedly during the transition, the effect becomes erratic. A seeded pseudo-random function that always produces the same sequence fixes the instability:

const mask = 0xffffffff;
const seed = 30; /* update this to change the generated sequence */
let m_w  = (123456789 + seed) & mask;
let m_z  = (987654321 - seed) & mask;

let random =  function() {
  m_z = (36969 * (m_z & 65535) + (m_z >>> 16)) & mask;
  m_w = (18000 * (m_w & 65535) + (m_w >>> 16)) & mask;
  var result = ((m_z << 16) + (m_w & 65535)) >>> 0;
  result /= 4294967296;
  return result;
}

The final effect is a nested loop producing NxM rectangles, a formula that staggers their alpha transitions, and a borrowed deterministic random function. The mask is applied to any element with the mask property and adjusted purely via CSS variables.

Eliminating gaps

At certain sizes, thin gaps appear between adjacent rectangles. They come from anti-aliasing at the edges. Making each rectangle slightly larger than its cell solves it:

The previous fill call:

ctx.fillRect(i*w, j*h, w, h);

becomes:

ctx.fillRect(i*w-.5, j*h-.5, w+.5, h+.5);

That added 0.5 offsets overlap the rectangles just enough to hide the seams. The value is not special — it can be tuned per use case.

Beyond rectangles

The canvas context supports any shape, so the mask is not limited to a grid. Triangular fragmentation, for instance, can be produced with Delaunay triangulation. Rather than implementing it from scratch, the Delaunator library does the work. Points are generated randomly, triangles are computed, and one CSS variable controls the number of points:

const n = properties.get('--f-n');
const o = properties.get('--f-o');
const w = size.width;
const h = size.height;
const l = 7; 

var dots = [[0,0],[0,w],[h,0],[w,h]]; /* we always include the corners */
/* we generate N random points within the area of the element */
for (var i = 0; i < n; i++) {
  dots.push([random() * w, random() * h]);
}
/**/
/* We call Delaunator to generate the triangles*/
var delaunay = Delaunator.from(dots);
var triangles = delaunay.triangles;
/**/
for (var i = 0; i < triangles.length; i += 3) { /* we loop the triangles points */
  /* we draw the path of the triangles */
  ctx.beginPath();
  ctx.moveTo(dots[triangles[i]][0]    , dots[triangles[i]][1]);
  ctx.lineTo(dots[triangles[i + 1]][0], dots[triangles[i + 1]][1]);
  ctx.lineTo(dots[triangles[i + 2]][0], dots[triangles[i + 2]][1]);  
  ctx.closePath();
  /**/
  var alpha = (random()*(l-1) + 1) - (1-o)*l; /* the alpha value */
  /* we fill the area of triangle with the semi-transparent color */
  ctx.fillStyle = 'rgba(0,0,0,'+alpha+')';
  /* we consider stroke to fight the gaps */
  ctx.strokeStyle = 'rgba(0,0,0,'+alpha+')';
  ctx.stroke();
  ctx.fill();
} 

Hexagonal fragmentation works the same way, with a variable R defining the hexagon size. With a drawing routine available for any shape, the mask behaves consistently as a CSS custom image.

From hover to full animations

The effect itself is surprisingly small in CSS. Animating opacity on hover:

img {
  opacity:1;
  transition:opacity 1s;
}

img:hover {
  opacity:0;
}

and the fragmentation counterpart:

img {
  -webkit-mask: paint(fragmentation);
  --f-o:1;
  transition:--f-o 1s;
}

img:hover {
  --f-o:0;
}

Because the mask reacts only to the element's style changes, integrating it into larger interactions is straightforward. Responsive sliders, noise effects, loading screens, and card hover treatments can all reuse the same palette of mask animations.

The Paint API shifts complexity from CSS into JavaScript. There are no convoluted mask declarations to maintain, and the drawing is handled by APIs that are already familiar to anyone who has worked with canvas.