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
@keyframesanimations (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-pathdefines 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-imagetakes 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.
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.
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
| Property | Accepted values | Default | Animatable |
|---|---|---|---|
clip-path | none, basic shape, url(#svg-clip), shape with geometry box | none | same-type shapes |
mask-image | none, <image> list (gradients, url()) | none | no (use a registered property) |
mask-size | as background-size | auto | yes |
mask-position | as background-position | 0% 0% | yes |
mask-repeat | as background-repeat | repeat | no |
mask-composite | add, subtract, intersect, exclude | add | no |
mask-mode | alpha, luminance, match-source | match-source | no |
mask-clip / mask-origin | box keywords | border-box | no |
mask (shorthand) | all of the above | — | per 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.
<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
- See the clip shape. In Chrome and Edge, select the element and click the shape icon next to
clip-pathin the Styles pane to draw and edit the shape on the page. Firefox's Shapes editor does the same, including dragging polygon points. - 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.
- 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. - 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.
- Test hit areas. Hover the edges of a clipped element; the cursor and hover styles should only respond within the visible shape.
Browser Compatibility
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
clip-path basic shapes + animation | Supported in all current versions | Supported in all current versions | Supported in all current versions |
Unprefixed mask-image, mask-composite | Supported in current versions | Supported in current versions | Supported in current versions |
@property (animatable gradients) | 85+ | 128+ | 16.4+ |
:has() (spotlight example) | 105+ | 121+ | 15.4+ |
prefers-reduced-motion | 74+ / 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
| Pitfall | Cause | Resolution |
|---|---|---|
| Shape jumps instead of morphing | Different shape functions or point counts | Same function; pad polygons to equal counts |
| Gradient mask or border does not animate | Unregistered custom property | Register it with @property and a typed syntax |
| Focus ring invisible | Outline is clipped with the element | Ring on an unclipped wrapper |
| Content hidden for reduced-motion users | Animation removed but start clip kept | Set the end state explicitly |
| Fade looks wrong in dark mode | Overlay gradient instead of a mask | Use 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
- Clip-Path Reveal Animations — wipes, irises and diagonal reveals.
- Morphing Shapes With clip-path polygon() — point-matched icon morphs.
- Fade Edges With mask-image — soft boundaries on any background.
- Animated Gradient Borders With @property — rotating rings with a registered angle.
- Performance & GPU Acceleration — what these effects cost to render.
- Aspect Ratio for Responsive Media — reserving stable boxes for clipped media.
Related articles
More pages in the same section.