SvitsGames

Your clip-path is eating your drop-shadow

Short version: CSS applies filter before clip-path. Put both on the same element and the drop-shadow is generated from the unclipped box, then thrown away by the clip — not dimmed, not partially cut. Measured in Chrome 151, the darkest pixel under the shape was the page background exactly, and the render was pixel-identical to the same element with no filter at all.

Put the filter on a wrapper and the clip-path on the child. The filter then operates on the already-clipped subtree, so the shadow traces the real silhouette.

The symptom: a shadow that never painted once

The site’s game cards are drawn as cartridges — a rectangle with shoulders, a grip recess notched out of the top, and a slight taper at the foot. The shape is a ten-point clip-path, and the card carried a drop-shadow so it would sit forward of the page rather than lie flat on it.

It didn’t. Not slightly — at all.

A missing shadow doesn’t throw, doesn’t warn, and doesn’t look like an error. It looks like a design choice, and a fairly defensible one. The card reads as slightly flat, and “slightly flat” is the kind of complaint you file under taste and come back to later.

What exposed it was comparison, not testing. The clipped shape and its shadow went in together, and on its own the cartridge looked plausible. It was putting several of them in a grid, next to elements that did have shadows, that made the absence obvious — at which point the question stopped being “is this flat enough” and started being “is this property doing anything at all.”

To be straight about the timeline: this was caught during the redesign session that introduced the shape, so the broken version never reached production. I’m writing it up anyway, because nothing about the process deserves credit for that. The rule was valid CSS, it was in the bundle, and it produced no output. Had the cartridge stayed a one-off on a holding page rather than becoming a grid, there is no step in how I work that would have caught it.

The order is specified, not incidental

This isn’t a Chrome quirk. CSS Masking Module Level 1, §2 Module interactions, defines the order plainly:

First the element is styled under absence of filter effects, masking, clipping and opacity. Then the element and its descendants are drawn on a temporary canvas. In a last step the following effects are applied to the element in order: filter effects, clipping, masking and opacity.

Filter effects, then clipping. So drop-shadow runs against the full rectangular box and produces the shadow of a rectangle. Only afterwards does the clip cut everything down to the polygon.

Which is why the result is total rather than partial, and this is the part worth being precise about. It’s tempting to assume you’d at least keep the bit of shadow that falls inside the clip region. You don’t — because the element itself fills that region opaquely and is painted over its own shadow. Inside the polygon the shadow is hidden; outside it, the shadow is clipped. There is nowhere left for it to show.

Measuring the missing shadow in headless Chrome

Three copies of the same clipped shape on a #3A4248 page, in headless Chrome 151. A has the filter and the clip on one element. B has the filter on a wrapper. C has no filter at all.

Sampling the band directly under each shape for the darkest pixel:

Variant Darkest pixel below the shape
Afilter + clip-path, same element (58, 66, 72) — the page background
Bfilter on wrapper, clip-path on child (33, 38, 42)
C — no filter (58, 66, 72) — the page background

A and C are the same number because they are the same picture. Diffing the two crops gives a maximum channel difference of 1/255, confined to a six-pixel band on the antialiased taper edge. Adding two drop-shadow filters to the clipped element changed nothing a screenshot can detect.

That’s the useful diagnostic, incidentally. If you suspect this, don’t squint at the shadow — delete the filter declaration entirely and see whether anything moves. If nothing does, it was never rendering.

box-shadow is not the escape hatch

The obvious next thought is to drop filter and use box-shadow, which doesn’t appear in that ordering list — filter effects, clipping, masking, opacity — at all. It doesn’t help, for a different reason: box-shadow is painted as part of the element, onto the temporary canvas, in the step before any of those four run. So it gets clipped too.

Same headless Chrome 151 setup as above — three copies of the shape on a #3A4248 page, darkest pixel in the band directly beneath each:

Variant Darkest pixel below the shape
box-shadow on the clipped element (58, 66, 72) — the page background
box-shadow on the wrapper (44, 50, 55)
box-shadow, no clip-path at all (44, 50, 55)

The first row is the same total disappearance. The last two rows are identical to each other, and that’s the actual argument against box-shadow on a wrapper: it traces the wrapper’s rectangle, so the clip makes no difference to it. You get a rectangular shadow behind a notched shape, which looks worse than no shadow — it reads as a misaligned card rather than as a moulded object.

drop-shadow on a wrapper is the only one of the four that follows the silhouette, notch and taper included, because it filters the subtree after that subtree has been clipped.

The previous design had the same bug, through mask

I assumed the pre-redesign cards were fine, on the grounds that they had no clip-path. They weren’t, and checking rather than assuming is the whole point of this post, so here is the correction.

The old card was a paper ticket with a torn-off bottom edge, and the tear was done with mask on the same element as the shadow:

.ticket__paper {
  box-shadow: 0 6px 16px rgb(0 0 0 / 0.35);
  --tear: radial-gradient(var(--scallop) at 50% 0, #0000 98%, #000)
    bottom / calc(2 * var(--scallop)) var(--scallop) repeat-x;
  mask: var(--tear), var(--body);
}

Look back at the ordering list: filter effects, clipping, masking, opacity. mask is in it, one step after clipping, and mask-clip defaults to the border box. So the box-shadow — painted onto the temporary canvas before any of those steps — is masked away exactly as clip-path would have removed it. Same rule, different property.

Measured the same way, the old declarations against a control with only the mask removed:

Variant Darkest pixel below the card
mask + box-shadow, same element (58, 66, 72) — the page background
box-shadow, mask removed (44, 50, 55)

That first number is the page background again, to the channel. The shadow on the old cards had never rendered either. Two designs, two different properties, the same silent no-op — which is a better argument than the one I thought I was making, because it means this isn’t a clip-path gotcha. It’s a property of where these three things sit in the painting order, and mask gets you the same way. The shadow didn’t break when the shape changed — it kept doing exactly what it had always done, which had stopped being the right thing.

A cartridge needs two drop-shadows, not one

Moving the filter to the wrapper is only half of why these cards sit forward of the page. The other half is that one shadow is never enough:

.cart {
  filter: drop-shadow(0 2px 2px rgb(0 0 0 / 0.45))
    drop-shadow(0 14px 22px rgb(0 0 0 / 0.4));
}

A tight 2px contact shadow and a wide 22px soft one. The first sits the object on the page; the second lifts it off. A single mid-sized shadow — which is what 0 6px 16px was — reads as blur behind a flat card, and that was most of why these sank into the background even in the version where the shadow did paint.

The two filters compose in sequence, so the second drop-shadow takes the output of the first as its input — it shadows the shape and the first shadow, rather than both being cast from the original alpha.

What the wrapper changes underneath you

Introducing a filtered wrapper is not a free refactor. filter on an element does two structural things beyond drawing: it becomes a containing block for absolutely and fixed positioned descendants, and it creates a stacking context.

Both are normative, in Filter Effects Module Level 1, §5 Graphic filters: the filter property:

A value other than none for the filter property results in the creation of a containing block for absolute and fixed positioned descendants unless the element it applies to is a document root element in the current browsing context.

A computed value of other than none results in the creation of a stacking context the same way that CSS opacity does.

The stacking context. Any z-index inside the card is now scoped to the card and cannot escape it. Here that’s what you want — the play control sits above the stretched link covering the cartridge, and that ordering is a local concern. This is inside the component’s scoped Astro <style> block, which is where the :global() comes from; it isn’t plain-stylesheet syntax. It’s needed because Astro’s scoping stops at the component boundary and the play control is a separate component:

.cart :global(.cta) {
  position: relative;
  z-index: 1;
}

The containing block. This one surprised me enough to measure. A wrapper with a filter and a static child, containing an inset: 0 absolutely positioned element:

wrapper       40,100 300x60
child(static) 40,100 300x60
inset:0 el    40,100 300x60      ← not the viewport's 780x200

The inset: 0 resolved against the filtered wrapper rather than the initial containing block. Without the filter it would have covered the viewport.

In the real component this doesn’t bite, because .cart__shell is position: relative and is the nearer ancestor, so the stretched-link ::after resolves against the shell — which is the clipped element, and the right answer. But that’s a property of the shell being positioned, not something the wrapper guarantees. Add a filtered wrapper around a subtree that doesn’t have a positioned ancestor and every position: absolute inside it quietly changes what it’s measured against.

A renamed class silently unhooked the reduced-motion rule

The same redesign produced a second silent no-op, in a different property, and it is worth setting beside the first.

The hover lift moved from .ticket to .cart__shell — the transform sits on the shell rather than the whole card, so the slot underneath stays put. The prefers-reduced-motion block in global.css had been written against the old names:

.ticket,
.ticket:hover,
a.cta:hover {
  transform: none !important;
}

A selector that no longer matches anything is not an error. It’s not a warning. It doesn’t show up in a diff as a removal, because nothing was removed. The stylesheet stays valid, the page looks correct, and the accessibility guarantee is simply gone — visible only to visitors who have reduced motion enabled, which is nobody on the development machine.

It got caught and updated in the same commit (dccabcb), so it never reached production. I’m including it because the catch was luck rather than process. A renamed class silently unhooks every rule that referenced it by the old name, and the rules most likely to go unnoticed are exactly the ones guarding conditions you don’t browse in — reduced motion, forced colours, print.

What I took from this

Both failures here are invisible-by-construction, and they share a shape: the code is still there, still valid, still parsed, and doing nothing. A deleted rule leaves a hole someone eventually notices. A rule that is present and inert looks like it’s working, and looks that way indefinitely.

The two of them together also make the case for comparison as a debugging tool. Neither was found by testing, because neither produces a failure to test for. The shadow was found by putting the shape next to something that had one. The selector was found by reading old and new side by side. If a bug’s only symptom is an absence, you need a present thing to hold it against.

So when a purely visual property doesn’t seem to be doing much, delete it before you tune it. If the page is pixel-identical without it, you weren’t looking at a subtle effect. You were looking at nothing.

Every number above is headless Chrome 151. I couldn’t get a second engine rendering headless in this environment to confirm the pixel measurements independently, so treat those as Chrome-specific and the ordering claim as what it is — normative text in css-masking-1 that every engine is meant to implement.

Three other posts here are the same species. On this site, a scoped Astro selector that skips the component it was written for — valid CSS, compiled into the bundle, matching nothing. In the game engine, a volume setter that looks dead and a font rule we had written down and never tested. Code that runs and achieves nothing, and a claim that reads as settled fact. All of them survived on the strength of nobody checking.