Clip-Path & Mask Animations: Animating Visibility Instead of Position

Most CSS motion moves things: transform slides, scales and rotates; opacity fades the whole element evenly. A second family of effects leaves the element exactly where it is and changes which parts of it are visible. Wipes, irises, shape morphs, soft edges, spotlights and gradient rings all belong to this family, and they are built from two properties — clip-path, which cuts an element to a shape, and mask-image, which sets per-pixel transparency from an image. This guide, part of CSS-Only Micro-Interactions & Animations, explains how each works, which values interpolate, what they cost to render, and how to keep them accessible.

Prerequisites — this guide assumes you can already:

  • Write CSS transitions and @keyframes animations (see CSS Transition Fundamentals).
  • Use linear, radial and conic gradients as background images.
  • Register a custom property with @property.
  • Gate motion behind prefers-reduced-motion.

The Core Concept: The Visible Region

Every rendered element has a region of pixels it paints. clip-path and mask-image both reduce that region after the element has been painted, without touching layout. The difference is in how they decide which pixels survive.

  • clip-path defines a geometric shape — inset(), circle(), ellipse(), polygon(), path(), or an SVG <clipPath> — and discards everything outside it. The boundary is hard: a pixel is either in or out. Hit testing follows the shape, so clicks outside it pass through to whatever is behind.
  • mask-image takes an image — usually a gradient — and multiplies each pixel's alpha by the mask's alpha (or luminance) at that point. The result can be partially transparent, which is how soft fades and feathered edges are made. Hit testing is not affected by a mask's transparency.
Where clip-path and mask act An element is painted into its box. clip-path then removes everything outside a hard-edged shape. mask-image then scales each remaining pixel's alpha by the mask, creating soft edges. The layout box is unchanged throughout. Paint, then clip, then mask; layout never changes 1. painted box 2. clip-path: hard 3. mask: soft alpha The dashed outline is the unchanged layout box: nothing around it moves. Clip also governs hit testing; the mask's softness does not.

Because the layout box never changes, these effects are free of the problems that plague size animations: no reflow of neighbours, no layout shift, no content jumping under the reader. That makes them natural choices for entrance effects and state changes on elements whose position must stay stable. The guides in this section each take one use: Clip-Path Reveal Animations for wipes and irises, Morphing Shapes With clip-path polygon() for icon and badge morphs, Fade Edges With mask-image for soft boundaries, and Animated Gradient Borders With @property for the registered-property technique that makes gradients animatable.

What interpolates, and what does not

Animation depends on interpolation, and the rules differ between the two properties.

Interpolation rules at a glance Same-type clip shapes interpolate directly; polygons need equal point counts; path needs identical commands; mask-position and mask-size interpolate; gradient images need a registered custom property. Can the browser tween it? inset() → inset(), circle() → circle() yes, directly polygon() → polygon() only with equal point counts path() → path() only with identical commands mask-position, mask-size yes, directly mask-image gradient stops via a registered @property

The bottom row is the general escape hatch. Any value that cannot interpolate as an image or shape can often be decomposed: the parts that change — an angle, a stop position, a radius — go into custom properties registered with a typed syntax, and the image or shape is rebuilt from them every frame. The registered properties and type safety guide explains why the registration, not the var(), is what makes this work.


Syntax and Parameters

PropertyAccepted valuesDefaultAnimatable
clip-pathnone, basic shape, url(#svg-clip), shape with geometry boxnonesame-type shapes
mask-imagenone, <image> list (gradients, url())noneno (use a registered property)
mask-sizeas background-sizeautoyes
mask-positionas background-position0% 0%yes
mask-repeatas background-repeatrepeatno
mask-compositeadd, subtract, intersect, excludeaddno
mask-modealpha, luminance, match-sourcematch-sourceno
mask-clip / mask-originbox keywordsborder-boxno
mask (shorthand)all of the aboveper longhand

Basic shapes accept an optional reference box, as in clip-path: circle(50%) padding-box, which decides the coordinate system for percentages. The default is border-box, which matches what most people expect for a clipped card.


Step-by-Step Implementation: A Spotlight That Follows Focus

The steps below build a feature grid in which the focused or hovered card is fully visible and the rest are dimmed through a mask — a spotlight effect that works with both pointer and keyboard.

Step 1: a grid of cards

.features {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
  gap: 1rem;
}
.feature {
  padding: 1.25rem;
  border-radius: 12px;
  background: #1e293b;
  color: #f1f5f9;
}

Step 2: dim the cards the user is not engaging with

/* When any card is hovered or focused, the others fade back. */
.features:has(.feature:is(:hover, :focus-within)) .feature:not(:hover, :focus-within) {
  opacity: 0.45;
}

A plain opacity change is the baseline; it already communicates focus and works everywhere :has() does.

Step 3: replace the flat dim with a soft radial mask

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

.feature {
  mask-image: radial-gradient(circle at 50% 40%, #000 var(--spot), rgb(0 0 0 / 0.45) calc(var(--spot) + 30%));
  --spot: 100%;
  transition: --spot 0.35s ease;
}
.features:has(.feature:is(:hover, :focus-within)) .feature:not(:hover, :focus-within) {
  --spot: 0%;
}

Step 4: keep the focused card's ring outside any mask

.feature:focus-within {
  outline: 2px solid #93c5fd;
  outline-offset: 3px;
}

The focused card's --spot is 100%, so its mask is fully opaque and its outline shows. Only unfocused cards are masked, which is why the ring survives.

Step 5: respect reduced motion

@media (prefers-reduced-motion: reduce) {
  .feature { transition: none; }
}

The dimming still happens for reduced-motion users; it simply happens instantly.


Annotated Production Example: An Image Comparison Slider

A before/after slider is a clip-path classic. The version below uses a native range input for the control, so it is keyboard and screen-reader accessible, and clip-path: inset() driven by a custom property to reveal the "after" image. The one line of script updates the property; everything visual is CSS.

Live demoBefore and after comparison clipped with inset()
This frame has no script, so the reveal is fixed at 50%; on a real page one line of script copies the range value into --pos.
<figure class="compare" style="--pos: 50%">
  <div class="compare__before" role="img" aria-label="Kitchen before renovation"></div>
  <div class="compare__after" role="img" aria-label="Kitchen after renovation"></div>
  <label class="compare__control">
    <span class="visually-hidden">Reveal amount</span>
    <input type="range" min="0" max="100" value="50"
           oninput="this.closest('.compare').style.setProperty('--pos', this.value + '%')">
  </label>
</figure>
.compare {
  position: relative;
  display: grid;
  aspect-ratio: 16 / 9;
  margin: 0;
  border-radius: 12px;
  overflow: hidden;
}

/* Both images occupy the same grid cell. */
.compare__before,
.compare__after { grid-area: 1 / 1; }

.compare__before { background: linear-gradient(135deg, #78716c, #44403c); }
.compare__after  { background: linear-gradient(135deg, #fde68a, #f59e0b); }

/* The after image is clipped from the right: only the part left of
   --pos is visible. inset() interpolates, so --pos could also be
   transitioned for an animated intro. */
.compare__after {
  clip-path: inset(0 calc(100% - var(--pos)) 0 0);
}

/* The native range input sits across the bottom as the real control. */
.compare__control {
  grid-area: 1 / 1;
  align-self: end;
  padding: 0.75rem;
}
.compare__control input { width: 100%; accent-color: #f8fafc; }
.compare__control input:focus-visible { outline: 2px solid #f8fafc; outline-offset: 2px; }

The native range input carries the whole accessibility story: it is focusable, operable with arrow keys, and announced with its value. A custom draggable handle built from a <div> would need all of that recreated with ARIA and script. The clip follows the input's value through --pos, so the visual and the accessible state cannot diverge.


Choosing Between Clip, Mask, Opacity and Transform

Four properties can make something appear or disappear, and choosing well is mostly about what the effect should say and what it may cost.

Opacity says "fading in or out" and nothing about direction. It is composited everywhere and is the cheapest option. Use it for anything that loops, anything that appears many times on a page, and anything where direction would be meaningless — toasts, tooltips, hover states.

Transform says "moving" or "growing". It is also composited and cheap. Use it when the content genuinely travels: a drawer sliding in, a card lifting on hover. Scale transforms distort text during the animation, which is acceptable for brief motion but not for content the reader is trying to read mid-animation.

Clip-path says "being uncovered" and has a direction or a centre. The content is sharp and in place from the first frame, which makes it ideal for headings, images and panels whose position must stay stable. It usually repaints, so keep it short.

Mask says "blending into its surroundings". It is the only one of the four that can produce partial transparency across part of an element, so fades at edges, spotlights and vignettes need it. Gradient mask animations repaint every frame.

A useful rule in design reviews: if an effect can be done with opacity or transform and still communicate the same thing, do it that way. Reach for clip and mask when the uncovering or the soft edge is the point of the effect, not a stylistic flourish on something opacity could handle.

Integration With Adjacent CSS

Container queries. Clip shapes written in percentages scale with the element, which is what a responsive component wants. Where a shape needs absolute proportions — a notch of fixed depth, a corner cut of 1rem — combine percentages with calc() or container query units so the geometry adapts to the component's width rather than the viewport's.

Scroll-driven animations. Reveals are most often wanted as elements enter the viewport. A view() timeline drives a clip or mask animation directly from scroll position, with no observer script, and the same keyframes serve both time-based and scroll-based versions.

View transitions. The snapshot pseudo-elements of a view transition accept clip-path and mask like any element, so a page transition can iris or wipe from old to new content by animating ::view-transition-new(root) with a clip.

Cascade layers. Motion effects are easier to disable wholesale when they live in their own layer. A motion layer holding every clip and mask animation can be neutralised by a single reduced-motion rule in a later layer.

@supports for progressive enhancement. Clip-path basic shapes are universal, but newer pieces — the shape() function, unprefixed mask composition in older engines, registered properties — are not. Guard them with feature queries such as @supports (mask-composite: intersect) or @supports (clip-path: shape(from 0 0, line to 100% 0)), and write the unguarded rule so the element is simply visible and unmasked. An effect that fails should leave content plainly readable, never half-clipped.

Custom properties as the control surface. Every example in this section exposes its moving part — a position, an angle, a radius — as a custom property. That makes the effect scriptable when needed (as in the comparison slider), themable, and trivially disabled: setting the property to its end value is the reduced-motion fallback.


Performance and Accessibility Notes

Rendering cost. transform and opacity animations are composited by default in every engine. clip-path and mask animations are not guaranteed to be: many repaint on the main thread each frame, and gradient masks rebuilt from registered properties always repaint. Keep these effects short, limit how many run simultaneously, and profile on mid-range hardware, as described in Profiling Animations in DevTools.

Clipped focus rings. Both properties clip the element's outline and shadow. A focus indicator on a clipped element may be partly or entirely invisible. Put the ring on an unclipped wrapper, or ensure the clip is removed in the focused state.

Hidden content is still content. Neither property removes content from the accessibility tree. Text clipped to nothing or masked to transparent is still read by screen readers. Use these effects only for content that is also visible in its final state.

Reduced motion. Sweeping reveals and continuous gradient rotations are exactly the motion that reduced-motion users are asking to avoid. Provide the end state instantly, following the patterns in prefers-reduced-motion Recipes.

Contrast at every frame. A fade that dims text below 4.5:1 contrast is acceptable only for content the user is not currently meant to read. Check the final, resting state for contrast, not just the start.


DevTools Debugging Workflow

  1. See the clip shape. In Chrome and Edge, select the element and click the shape icon next to clip-path in the Styles pane to draw and edit the shape on the page. Firefox's Shapes editor does the same, including dragging polygon points.
  2. Inspect interpolation. The Animations panel in Chromium and Firefox lets you scrub a running animation. Pause at 50% and look for twisted polygons or jumping shapes, which indicate mismatched point counts or shape types.
  3. Check registered properties. In the Computed pane, a registered custom property shows a typed value such as 180deg; an unregistered one shows the raw token string. If a gradient animation snaps, check whether the property is registered.
  4. Measure paint. Enable Paint flashing in the Rendering drawer. Green flashes on every frame during a clip or mask animation confirm main-thread repaints — acceptable for short effects, a warning sign for loops.
  5. Test hit areas. Hover the edges of a clipped element; the cursor and hover styles should only respond within the visible shape.

Browser Compatibility

FeatureChrome / EdgeFirefoxSafari
clip-path basic shapes + animationSupported in all current versionsSupported in all current versionsSupported in all current versions
Unprefixed mask-image, mask-compositeSupported in current versionsSupported in current versionsSupported in current versions
@property (animatable gradients)85+128+16.4+
:has() (spotlight example)105+121+15.4+
prefers-reduced-motion74+ / 79+63+10.1+
accent-color (slider example)93+92+26.2+

Older WebKit and Blink versions required -webkit-mask-*; declaring the prefixed longhands before the unprefixed ones is still a reasonable safety net for sites that support older browser versions.


Common Pitfalls

PitfallCauseResolution
Shape jumps instead of morphingDifferent shape functions or point countsSame function; pad polygons to equal counts
Gradient mask or border does not animateUnregistered custom propertyRegister it with @property and a typed syntax
Focus ring invisibleOutline is clipped with the elementRing on an unclipped wrapper
Content hidden for reduced-motion usersAnimation removed but start clip keptSet the end state explicitly
Fade looks wrong in dark modeOverlay gradient instead of a maskUse mask-image, which has no colour

FAQ

What is the difference between clip-path and mask-image?clip-path cuts an element to a geometric shape with hard edges: every pixel is either fully visible or hidden. mask-image uses an image's alpha or luminance to set partial transparency per pixel, so it can produce soft fades and textured edges. Clip-path shapes interpolate directly; mask gradients need a registered custom property to animate.

Do clip-path and mask affect layout? No. Neither changes the element's box, so surrounding content never moves. Both do affect hit testing for clip-path, where clicks outside the clipped shape pass through, and both clip the element's outline and box-shadow, which matters for focus indicators.

Are clip-path and mask animations GPU accelerated? Partly and inconsistently. Some engines can composite certain mask and clip animations, but many clip-path and gradient-mask animations repaint on the main thread each frame. Treat them as moderately expensive: fine for short, one-off effects, poor for long loops or many elements at once.

How do I keep a focus ring visible on a clipped element? The clip also clips the outline. Either clip an inner element and put the focus ring on an unclipped wrapper, or use an outline drawn inside the visible shape with a negative outline-offset. Never rely on the clipped shape itself to show focus.

What should reduced-motion users see instead of a reveal? The final state, immediately. Remove the animation and set the clip or mask to its end value, or remove it entirely. Simply dropping the animation can leave the element stuck at its starting clip, which is often fully hidden.

Related articles

More pages in the same section.