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 |
|---|---|
A — filter + clip-path, same element |
(58, 66, 72) — the page background |
B — filter 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
nonefor thefilterproperty 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
noneresults in the creation of a stacking context the same way that CSSopacitydoes.
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.