Updated November 9, 2026  ·  9 min read  ·  SVGVector

Figma SVG Clipping Mask Fix: Why the Mask Eats the Artwork

A mask in SVG is not a shape that reveals what is inside it. It is a picture whose brightness becomes the alpha channel of the element it is applied to. Almost every "my mask exported wrong" case follows from that sentence: the mask is fine, the picture inside it is the wrong shade, or the frame around the picture is smaller than the artwork.

Each case below was reproduced in Chrome with a synthetic Figma-style export — a 200 × 200 artboard, a mask frame, one painted shape — and measured by reading the alpha channel back pixel by pixel after rasterising. The numbers are what the browser painted, not what the markup suggests it should paint. On a white mask the result is 255; on a mid grey it is 128; on black it is 0. That single table explains more Figma exports than any other page on this site.

What Figma writes when you choose "Use as mask"

Select a shape, run Use as mask, and the layer above it becomes the mask content. On export, Figma writes that pair into a <defs> block and points the artwork at it:

<defs>
  <mask id="mask0_12_4" style="mask-type:luminance"
        maskUnits="userSpaceOnUse" x="0" y="0" width="200" height="200">
    <rect width="200" height="200" fill="#ffffff"/>
    <circle cx="100" cy="100" r="60" fill="#ffffff"/>
  </mask>
</defs>
<g mask="url(#mask0_12_4)">
  <!-- the artwork that was under the mask layer -->
</g>

Three details matter, and each one is a failure mode later on. The first white rectangle is not decoration: it is the part of the artboard you keep. The circle adds to it. Where neither of them paints, nothing of the artwork survives. Second, the x/y/width/height attributes are the mask region — a hard rectangle outside which the mask is treated as black. Third, mask-type decides whether colour or alpha is read, and the default is luminance.

Cause 1: a mask is luminance, not coverage

The most common report is "the mask hid everything". It usually did exactly what the file asked. Measured on a 200 × 200 rect filled #5850ec and masked by a solid 200 × 200 rectangle, changing only the fill of the mask content:

Mask content fillResulting alphaWhat you see
#ffffff255Artwork fully visible
#808080128Artwork at 50%
#0000000Nothing at all
fill="none"0Nothing at all
#000000 + mask-type:alpha255Artwork fully visible

Two conclusions, both measured. Black in a luminance mask is a hole, and a shape with fill="none" paints no pixels, so it is a hole as well — a mask layer whose fill was switched off is indistinguishable from a mask layer that was deleted. And once mask-type:alpha is in play, colour stops mattering: the same black rectangle that hid everything now hides nothing, because only its alpha channel is read.

This is why "the mask is inverted" is usually the wrong diagnosis. Nothing is inverted; the mask content is dark where you needed it light, or the mask is being read in alpha mode where you assumed luminance. Figma has no invert switch for masks, so an inverted result means the fills were swapped — a white shape on a black frame instead of a black shape on a white frame.

Cause 2: an id that resolves to nothing masks nothing

Rename an id, paste two exports into one file, or run a minifier that shortens ids, and the artwork's mask="url(#mask0_12_4)" can end up pointing at a definition that no longer exists. Chrome does not treat that as an error and it does not hide the element. Measured on the same 200 × 200 fixture: a dangling reference paints 40,000 pixels — exactly the same count as no mask at all — while the correctly referenced mask paints 20,000.

The clip-path case behaves the same way: a dangling clip-path="url(#clip0_12_4)" also paints 40,000 pixels, and a real one paints 20,000. So "the mask stopped working after I optimised the file" is usually a broken reference, not a rendering difference. The fix is mechanical: count the ids you reference and the ids you define.

// paste in the console with the file open in the browser
const ids = new Set([...document.querySelectorAll('[id]')].map((n) => n.id));
const dangling = [...document.querySelectorAll('[mask],[clip-path]')]
  .flatMap((n) => ['mask', 'clip-path'].map((a) => n.getAttribute(a)))
  .filter(Boolean)
  .map((v) => v.match(/#(.+)\)/)[1])
  .filter((id) => !ids.has(id));
console.log(dangling);   // any entry here is a mask that is doing nothing

Cause 3: the region behind the mask is a hard edge

The x/y/width/height attributes on <mask> are not hints. They are the rectangle the mask is allowed to exist in; outside it, alpha is zero. Take a 200-pixel-wide artwork that is white all the way across and a mask region of 100 pixels:

Mask region widthPainted pixelsInk boxAlpha at x = 150
20040,000200 × 200255
10020,000100 × 2000

The edge lands exactly on 100 and is perfectly straight, which is the giveaway: curves that end in a straight line, or artwork that stops at a clean vertical seam, are region problems. Figma fills those attributes from the bounds of the mask frame at export time, so moving or resizing the artwork afterwards — or exporting a different node than the one the mask was drawn on — leaves the numbers stale. Recomputing them from the root viewBox restores the full 40,000 pixels; that is what the snippet later in this article does.

Cause 4: one pixel survives, because the units disagree

Here is a failure that looks like a corrupted file and is only a units mismatch. A clip path carries clipPathUnits, and its coordinates are read in those units. Write 0..1 fractions while declaring user-space units, and the clip collapses to a single unit:

MarkupPainted pixels
clipPathUnits="userSpaceOnUse", rect width="100"10,000
clipPathUnits="userSpaceOnUse", rect width="1"1
clipPathUnits="objectBoundingBox", rect width="0.5"10,000

One pixel. Not zero, which is why it reads as a rendering bug rather than a clipping bug: a thin line of colour survives at the top-left, or the artwork simply is not there while the file size and the layer list look normal. Any tool that "normalises coordinates" to a 0..1 range has to flip clipPathUnits in the same edit; when it does not, this is the result.

Cause 5: a clip cannot fade

A clip path is a stencil and a mask is a ramp, and the two are not interchangeable. Sample the alpha of the same gradient-shaped stencil five times across a 200-pixel width:

Applied asAlpha at 1 / 50 / 100 / 150 / 198 px
mask with a white-to-transparent gradient2 · 64 · 128 · 192 · 253
clip-path with the same geometry255 · 255 · 255 · 255 · 255

So when a soft edge exports as a razor cut, the file is clipping, not masking. The reverse also happens: a mask used where a binary cut was intended leaves an unintended 50% band along the edge, which shows up as a grey outline on a dark background and gets blamed on anti-aliasing.

Cause 6: nested masks multiply

Two masks on one element do not add up, they intersect. Measured on the same fixture: a mask that keeps the left half paints 20,000 pixels, a mask that keeps the top half paints 20,000 pixels, and both together paint 10,000 — one quadrant, which is exactly 50% of either mask's area.

This is why "I removed the mask and it is still cut" is a real experience: Figma can emit a mask on the group and another on a child inside it, and each one is a separate multiplication. Flatten the mask layers before exporting, or accept the intersection deliberately.

Cause 7: masks cut strokes and shadows too

A mask multiplies the alpha of everything the element paints, not just its fills. A ring — a circle with fill="none" and a 20-unit stroke — masks in half like a solid disc: 7,788 painted pixels become 3,894, and the surviving ink box is 70 × 140 instead of 140 × 140. If a border disappears while its fill survives, this is the mechanism, and a drop shadow or blur inside the masked group is cut the same way.

What is not the problem: the element transform

It is tempting to blame the transform — "the mask did not follow the shape when I scaled it". It did. Applying transform="scale(2)" to the masked element takes the painted area from 11,436 pixels to 45,500 and the ink box from 120 × 120 to 240 × 240, which is four times the area, matching the circle's own area growth (11,310 at r = 60). Mask content lives in the same user space as the element that references it, so it scales with it.

What does not follow is the viewBox. Change the root viewBox and every user-space coordinate, mask regions included, is reinterpreted against the new canvas — which is the subject of the viewBox fix article rather than this one.

Fixing it in Figma before export

  1. Check the mask frame covers the artwork. The frame's bounds become the mask region; if it is smaller than the art, the art is cut with a straight edge no matter how white the mask is.
  2. Give the mask layer a visible white fill. A frame with the fill switched off exports as fill="none", which paints 0 and hides everything.
  3. Keep mask opacity at 100%. Layer opacity on the mask layer becomes grey in the export, and grey is a partial reveal — 50% opacity measures 128, not 255.
  4. One mask per group. Nested masks intersect; if the artwork is cut twice, remove the inner one instead of adjusting it.
  5. Flatten strokes you want to keep whole. Outlining a stroke before masking makes the mask cut a shape rather than a paint operation, which is easier to reason about.
  6. Export, then grep. grep -c 'url(#' file.svg and grep -c '<mask id' file.svg should agree, or know which references are gradients.

Fixing it in the file

If the artwork is right and the region is stale, rewrite the region from the root viewBox. This six-line snippet, run with the file open in a browser, measured 20,000 painted pixels before and 40,000 after on the fixture above:

const svg = document.querySelector('svg');
const vb = svg.viewBox.baseVal;
svg.querySelectorAll('mask').forEach((m) => {
  m.setAttribute('maskUnits', 'userSpaceOnUse');
  m.setAttribute('x', vb.x);
  m.setAttribute('y', vb.y);
  m.setAttribute('width', vb.width);
  m.setAttribute('height', vb.height);
});

If the mask content is solid white — no fades anywhere — the same drawing survives as a clip path, which is cheaper and immune to the luminance rules. A clip is also what you want when the answer to "should this edge be hard?" is yes. If a gradient stops halfway across and turns into a hard cut, the mask was replaced by a clip somewhere in the pipeline; put the gradient back and keep it as a mask.

A thirty-second check

The one-line rule: a mask is a greyscale picture used as alpha — white reveals 255, grey reveals 128, black reveals 0 — so a mask that hides everything is usually a mask whose content is dark or missing its fill, and a mask that cuts in a straight line has a region smaller than the artwork.

Four measured mask failures: white, grey and black mask fills painting alpha 255, 128 and 0 with the same black shape painting 255 under mask-type alpha; a dangling mask id painting 40000 pixels against 20000 for a real mask; a 200 pixel artwork cut at exactly 100 pixels by the mask region; and one pixel surviving when clipPathUnits is userSpaceOnUse but the rect carries 0 to 1 coordinates.
Chrome alpha samples for the four mask failures that account for most "the mask exported wrong" reports.

Frequently asked questions

Why does my Figma mask hide the whole artwork?

Because the mask content is dark or empty. A mask is read as luminance by default: measured, a white fill reveals 255, mid grey 128, black 0, and fill="none" also 0. Check the fill of the mask layer — Figma exports a frame with its fill switched off, which hides everything.

Why is only part of the artwork visible, with a straight edge?

The mask region is smaller than the artwork. The x/y/width/height attributes are a hard rectangle: with a 100-pixel region on a 200-pixel artwork, measured ink stops at 100 pixels and alpha at x = 150 is 0. Reset the region to the root viewBox.

Why did the mask stop working after optimising the SVG?

An id was renamed. A mask reference that resolves to nothing is silently ignored in Chrome: measured, the element paints 40,000 pixels — the same as no mask — instead of 20,000. Clip paths behave identically. Count the ids you define against the ids you reference.

Can I use clip-path instead of a mask?

Only when the edge should be hard. A clip is a stencil: measured alpha across a gradient stencil is 255 everywhere, while the same stencil as a mask ramps 2 to 253. For solid cut-outs a clip is smaller and safer; for fades, keep the mask.

Related SVG Workflows

Masks are the third thing to break in a Figma export, after the canvas and the borders. These pages cover the rest of the pipeline.

Try These Free Online Tools

No download required — convert, compress & optimize SVGs right in your browser

Validate

SVG Validator

Catch dangling url(#…) references and duplicate ids before a mask silently stops working.

Open validator
Edit

SVG Editor

Fix a mask region, a clip unit or an id by hand, directly in the browser.

Open editor
Rasterise

SVG to PNG

Render at 2× to see a clipped or half-hidden file the way a page will.

Convert to PNG
Clean

Background Remover

Drop a full-bleed plate and keep the transparent pixels around the artwork.

Remove background
Optimise

SVG Optimiser

Minify a Figma export without renaming the ids your masks depend on.

Open optimiser
Embed

SVG to Data URI

Inline a cleaned file into CSS or HTML as a single data URI.

Build data URI