Animating clip-path and Mask Reveals

Part of Compositor-Only Property Optimization in Performance Budgeting & GPU Architecture.

The problem

Wipes and shape reveals are a staple of editorial and marketing motion: an image uncovered left to right, a hero expanding from a circle around the cursor, a section heading revealed behind a sliding edge. clip-path makes them easy to write — animate inset(0 100% 0 0) to inset(0 0 0 0) and the wipe is done. On a phone, a full-width wipe over a large image drops frames, and the same effect with a gradient mask-image is worse.

The question is whether these properties are compositor-friendly, and if not, how to get the same look without paying for paint on every frame.

Root cause analysis: a clip changes what is painted

transform and opacity change how an already-painted layer is drawn. clip-path and mask change which pixels of the element are visible, and in most engines that is resolved during paint: each frame computes the new clip region, invalidates the area it affects and repaints the element’s content inside it. The cost scales with the area of the element, not with how much of the clip moved.

Engines have been working on running some clip-path animations on the compositor — the clip is geometry the GPU can apply to an existing texture — but support is partial, depends on the shape type and on the element not being affected by other paint-dependent properties, and is not something to build on without verifying it in your target browsers. The safe assumption is that a clip-path animation is a paint animation, as the compositor-only property reference classifies it.

Masks are more expensive still. A gradient mask-image must be rasterised and combined with the element’s pixels, and animating mask-position or mask-size repeats that per frame. url() references to SVG <clipPath> or <mask> elements add a layer of indirection and are consistently main-thread work.

Main-thread paint per frame for a 1200 by 800 image revealMid-range phone, DPR 2. The transform wrapper produces the same visual wipe.Main-thread paint per frame for a 1200 by 800 image revealbudget 16.7 msTransform wrapper + counter-transform0.3 msclip-path: inset() wipe7.9 msclip-path: circle() reveal8.4 msmask-image gradient, animated position14.8 msclip-path: url(#svg-clip)22.6 ms
Mid-range phone, DPR 2. The transform wrapper produces the same visual wipe.

Decision matrix

Reveal Element size Use
Rectangular wipe Large (hero, full-width image) Transform wrapper with counter-transformed child
Rectangular wipe Small (button, thumbnail, heading) clip-path: inset()
Circle or ellipse from a point Any, brief (under 400ms) clip-path: circle() with containment
Circle from a point Large, slow A scaled circular element over the content, or a cross-fade
Soft-edged gradient wipe Small mask-image with animated mask-position
Soft-edged gradient wipe Large A gradient overlay element animated with transform
Arbitrary shape morph Any clip-path: polygon() with the same number of points in both states
Picking a reveal technique by shape and sizeRectangular and large goes to transforms; small or curved can afford a bounded clip-path.Picking a reveal technique by shape and sizeReveal neededRectangular?wipeLarge area?hero, full-widthTransformwrappercompositorOtherwiseclip-path + contain
Rectangular and large goes to transforms; small or curved can afford a bounded clip-path.

Polygon morphs follow the same rule as SVG path morphing: both polygon() values need the same number of points, or the property swaps discretely at the midpoint instead of interpolating.

Production code pattern

<figure class="wipe">
  <div class="wipe__window">
    <img class="wipe__image" src="harbour.avif" alt="Harbour at dawn" width="1200" height="800">
  </div>
</figure>
/* Large wipe on the compositor: move a clipping window right, move its content left. */
.wipe__window {
  overflow: clip;                         /* the window clips; no clip-path involved */
  transform: translateX(-100%);           /* window starts fully to the left */
}
.wipe__image {
  display: block;
  transform: translateX(100%);            /* content offset equally the other way: stays put */
}
.wipe.is-revealed :is(.wipe__window, .wipe__image) {
  transform: translateX(0);
  transition: transform 700ms cubic-bezier(0.65, 0, 0.35, 1);
}

/* Small element: clip-path is simpler and the paint area is trivial. */
.tag {
  clip-path: inset(0 100% 0 0);
  contain: paint;                          /* bound the repaint to the tag's box */
  transition: clip-path 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tag.is-revealed {
  clip-path: inset(0 0 0 0);
}

@media (prefers-reduced-motion: reduce) {
  .wipe__window, .wipe__image { transform: none; }
  .wipe.is-revealed :is(.wipe__window, .wipe__image) { transition: none; }
  .tag { clip-path: none; opacity: 0; transition: opacity 150ms linear; }
  .tag.is-revealed { opacity: 1; }
}

Rendering Impact: composite for the wipe, small paint for the tag. The window and image are two layers moving in opposite directions by the same distance, so the image appears stationary while the visible region grows; nothing is repainted after the first frame.

The wrapper technique relies on two transforms cancelling exactly. Both elements use the same timing function and duration, and because both are applied on the compositor they stay in lock-step. It works for any rectangular wipe direction — swap the axis and sign — but not for circular or diagonal shapes, which is where clip-path with containment remains the pragmatic choice.

The same left-to-right wipe, built two waysVisually identical; they differ in which thread does the work each frame.The same left-to-right wipe, built two waysclip-path: inset()One element, one propertyClip resolved in paint every frameCost grows with image areaFine small, heavy largeTransform window + counter-transformWrapper and child, two transformsBoth layers move on the compositorCost independent of image areaComposite only
Visually identical; they differ in which thread does the work each frame.

Verification checklist

Constraints and trade-offs

  • The transform wrapper needs an extra element and only produces rectangular wipes.
  • Two promoted layers for the wipe cost texture memory for the duration; release any will-change once the reveal completes.
  • clip-path animations may be composited in some engines in some cases; do not rely on it, but do not be surprised when a trace shows less paint than expected.
  • overflow: clip does not create a scroll container, which keeps the wrapper cheap, but it also clips focus rings on focusable content inside.
  • Soft-edged reveals with masks are the most expensive option and should be brief even on small elements.

Frequently asked questions

Is clip-path animation hardware accelerated?

Not reliably. In most engines and most cases it is resolved during paint on the main thread. Some engines can composite certain clip-path animations, but support varies, so treat it as a paint animation and verify in DevTools.

Why does my polygon clip-path jump instead of animating?

The start and end polygons have different numbers of points. Interpolation pairs points by index, so both values must list the same count; add duplicate points to the simpler shape.

Can I animate mask-image itself?

Images are not interpolable, so the mask swaps discretely. Animate mask-position or mask-size of a gradient mask instead, and keep the element small, because every frame recomposites the mask.

Is the transform wrapper accessible?

Yes. The content is present and in reading order throughout; only its visible region changes. Make sure the reveal is not the only indication that content has appeared, and that clipped focus indicators are handled.