Gradient Shadows: Beyond the Simple Trick
Can you create a shadow from a gradient rather than a solid color? There is no dedicated CSS property for this. The standard workaround — layering a pseudo-element with a blurred gradient behind the main element — works fine in most cases, but it falls apart when you need a transparent element background, or when borders and border-radius enter the picture. Let’s break down why these issues occur and how to solve each one.
The Baseline: Blurring a Gradient Behind Your Element
The common solution is to leverage a pseudo-element containing the gradient, position it behind the main element, and apply a blur filter to it. We can translate the rules typically written for box-shadow — X/Y offsets, blur radius, and spread distance — into this setup. Note that we use a negative inset value to get the spread distance.
.box {
position: relative;
}
.box::before {
content: "";
position: absolute;
inset: -5px; /* control the spread */
transform: translate(10px, 8px); /* control the offsets */
z-index: -1; /* place the element behind */
background: /* your gradient here */;
filter: blur(10px); /* control the blur */
}
Compare that effort to the equivalent solid-color shadow with a single box-shadow:
box-shadow: 10px 8px 10px 5px orange;
You might notice that the two shadows look slightly different on the blur edge; the filter algorithm simply doesn’t work the same way as box-shadow. The result is usually close enough. But this approach carries a hidden problem: the z-index: -1 declaration. As soon as the main element gets a transform, a new stacking context is created, and the pseudo-element is no longer behind it.
We can work around this by using a 3D transform instead of a negative z-index. Move the pseudo-element along the Z-axis — inside translate3d() — and set transform-style: preserve-3d on the main element so the transform takes effect:
.box {
position: relative;
transform-style: preserve-3d;
}
.box::before {
content: "";
position: absolute;
inset: -5px;
transform: translate3d(10px, 8px, -1px); /* (X, Y, Z) */
background: /* .. */;
filter: blur(10px);
}
Alternatively, if you can’t or don’t want to use a 3D transform, you can use both ::before and ::after. The first pseudo-element creates the gradient shadow; the second one replicates the main element’s background and other critical styles so you can control the stacking order explicitly. Just remember that pseudo-elements use the padding box as their coordinate reference — if the main element has a border, your inset values must account for that, as shown here with inset: -2px to match a 2px border:
.box {
position: relative;
z-index: 0; /* We force a stacking context */
}
/* Creates the shadow */
.box::before {
content: "";
position: absolute;
z-index: -2;
inset: -5px;
transform: translate(10px, 8px);
background: /* .. */;
filter: blur(10px);
}
/* Reproduces the main element styles */
.box::after {
content: """;
position: absolute;
z-index: -1;
inset: 0;
/* Inherit all the decorations defined on the main element */
background: inherit;
border: inherit;
box-shadow: inherit;
}
Keep in mind that you need to force the main element to create its own stacking context, which you can do by declaring z-index: 0 (or any other property that also establishes a stacking context). These solutions work well in practice whenever you’re dealing with a solid, non-transparent element background.
The Transparency Problem: Clipping Inside the Element
What happens when you remove the background from the main element? The pseudo-element behind it — with its blurred gradient — becomes visible through the transparent area, destroying the illusion.
On the surface, we need to cut or hide the part of the shadow that falls inside the main element’s area while keeping everything outside it. clip-path can’t make a hole totally inside an element’s region, but we can simulate that by using a polygon with repeated points. Building on the 3D-transform solution with offsets and spread distance set to zero, adding a single clip-path does the trick:
clip-path: polygon(-100vmax -100vmax,100vmax -100vmax,100vmax 100vmax,-100vmax 100vmax,-100vmax -100vmax,0 0,0 100%,100% 100%,100% 0,0 0)
The polygon contains a total of ten points. Four are defined with a very large offset (100vmax in this case), positioning them far outside the viewport. These expand the visible area so the blurred shadow is still rendered. The other four points sit exactly at the corners of the pseudo-element. The path goes from negative large coordinates to the element corners, drawing a thin sliver around the outside of the main element.

This concise example features zero offsets and spread, but for a general case, the clip-path must be aware of both offsets (--x, --y) and spread (--s). When you use a negative inset to create spread, the pseudo-element grows beyond the main element, meaning the cutter needs to be adjusted. The zero and 100% coordinate values have to become variable-driven boundaries:
.box {
--s: 10px; /* the spread */
position: relative;
}
.box::before {
inset: calc(-1 * var(--s));
clip-path: polygon(
-100vmax -100vmax,
100vmax -100vmax,
100vmax 100vmax,
-100vmax 100vmax,
-100vmax -100vmax,
calc(0px + var(--s)) calc(0px + var(--s)),
calc(0px + var(--s)) calc(100% - var(--s)),
calc(100% - var(--s)) calc(100% - var(--s)),
calc(100% - var(--s)) calc(0px + var(--s)),
calc(0px + var(--s)) calc(0px + var(--s))
);
}
Offsets introduce a similar correction. If the pseudo-element is displaced via translate(), the clip-path points need to move in the opposite direction to keep the outline perfectly aligned around the element:
.box {
--s: 10px; /* the spread */
--x: 10px; /* X offset */
--y: 8px; /* Y offset */
position: relative;
}
.box::before {
inset: calc(-1 * var(--s));
transform: translate3d(var(--x), var(--y), -1px);
clip-path: polygon(
-100vmax -100vmax,
100vmax -100vmax,
100vmax 100vmax,
-100vmax 100vmax,
-100vmax -100vmax,
calc(0px + var(--s) - var(--x)) calc(0px + var(--s) - var(--y)),
calc(0px + var(--s) - var(--x)) calc(100% - var(--s) - var(--y)),
calc(100% - var(--s) - var(--x)) calc(100% - var(--s) - var(--y)),
calc(100% - var(--s) - var(--x)) calc(0px + var(--s) - var(--y)),
calc(0px + var(--s) - var(--x)) calc(0px + var(--s) - var(--y))
);
}
Now the whole setup is governed by four variables: the gradient itself, the blur filter, the spread distance, and the X/Y offsets. Note how any border on the main element creates overlap because pseudo-elements align with the padding box. You can compensate with a 3D transform, or simply adjust the inset value to match the border thickness. The latter is often more accurate since the spread distance starts from the border-box rather than the padding-box, but it requires you to know the border width.
Transparency With Rounded Corners: Getting Into Masking
Turning on border-radius breaks the clip-path approach entirely — clip paths can’t reproduce curved boundaries. The straightforward fix for non-transparent cases is just setting border-radius: inherit on the pseudo-element, but the transparent case is more laborious.
The only reliable way to cut the rounded shape without bending clip-path is to switch to mask. Masks can’t work with an element’s “outside” area, so an extra element is introduced strictly to simulate it. Any element will work, but a dedicated custom component is safer than a <div>, which can be targeted by foreign CSS rules:
<div class="box">
<sh></sh>
</div>
First, position this <sh> element precisely over the main one, intentionally creating overflow:
.box {
--r: 50px;
position: relative;
border-radius: var(--r);
}
.box sh {
position: absolute;
inset: -150px;
border: 150px solid #0000;
border-radius: calc(150px + var(--r));
}
The techniques from the previous sections run unchanged on <sh>’s pseudo-element — blur, gradient, and negative inset — except that 3D transform now moves the entire extra element rather than just a pseudo-element:
.box {
--r: 50px;
position: relative;
border-radius: var(--r);
transform-style: preserve-3d;
}
.box sh {
position: absolute;
inset: -150px;
border: 150px solid #0000;
border-radius: calc(150px + var(--r));
transform: translateZ(-1px)
}
.box sh::before {
content: "";
position: absolute;
inset: -5px;
border-radius: var(--r);
background: /* Your gradient */;
filter: blur(10px);
transform: translate(10px,8px);
}
This is the crucial bit: the <sh> element participates in the mask, covering only the main element’s region, but its overflowing pseudo-element still paints the shadow outside everything. The extra element’s padding box is aligned with the main element’s padding box, so no further geometry math is needed for the pseudo-element.
With this structure established, apply the final mask to the <sh> element. Only two basic gradients and a single mask-composite invocation are necessary:
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
To get a mental model of the mask, isolate the extra element. A huge border relative to the element defines a kind of doughnut shape. If the main border-radius is R and you give the extra element a border of 150px with a radius of 150px + R, then the inner radius becomes exactly R. The border area (the “doughnut”) is what stays visible — everything else must go.
.box sh {
position: absolute;
inset: -150px;
border: 150px solid red;
background: lightblue;
border-radius: calc(150px + var(--r));
}
That requires two mask layers: one that exclusively covers the content-box area and another covering the border-box area by default. The mask-composite trick excludes the content-box layer from the border-box layer, leaving you with precisely the border ring:
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
Border-Radius Method Drawbacks
Nothing’s perfect, and some of the easy-to-miss details cause tiny glitches. For instance, a border thickness mismatch misaligns the radii. If you don’t explicitly mirror the main element’s border onto the <sh> element’s inset, you’ll notice small inaccuracies in the curve — fix that by adjusting the inset value:
.box {
--r: 50px;
border-radius: var(--r);
border: 2px solid;
}
.box sh {
position: absolute;
inset: -152px; /* 150px + 2px */
border: 150px solid #0000;
border-radius: calc(150px + var(--r));
}
The odd selection of a border width in the example isn’t magical; it must be sufficiently large to contain the shadow from your settings while being small enough to avoid unwanted overflow and scrollbars. That number will differ per project.
The approach also becomes more verbose if you need distinct radius values for individual corners. It’s not really a drawback, because each corner variable can be a customizable component of the final style sheet, but handling it well requires, at minimum, granular CSS variables. A uniform border-radius keeps everything manageable:
.box {
--r-top: 10px;
--r-right: 40px;
--r-bottom: 30px;
--r-left: 20px;
border-radius: var(--r-top) var(--r-right) var(--r-bottom) var(--r-left);
}
.box sh {
border-radius: calc(150px + var(--r-top)) calc(150px + var(--r-right)) calc(150px + var(--r-bottom)) calc(150px + var(--r-left));
}
.box sh:before {
border-radius: var(--r-top) var(--r-right) var(--r-bottom) var(--r-left);
}
By the end, you should be able to see gradient shadows as a composition of clever primitives — thinking about clip-path, 3D transforms, and mask-composite now accurately defines the problem and reduces the odds of a magic-number surprise.



