Fading isn't blurring

A progressive blur is the soft edge under a frosted header, or at the bottom of a lyrics screen where the lines drift out of focus. It should move from sharp to soft without ever looking like a second image laid on top.

The obvious implementation is one backdrop-filter, faded in with a gradient mask. One element, two lines of CSS. It is also the wrong mental model: a mask changes how much of a blur you see, not how strong the blur is at that point.

ghosting
Same stops, same final blur.

In the middle of the fade, the sharp photo is still underneath the blurry copy. Look at the street lights: crisp dots sitting inside their own glow. It reads as a smudge, or a double exposure, rather than something slowly going out of focus.

The stacked version keeps the same start, end and maximum blur. It only changes the path between them.

What the mask does

A mask decides how much of the blur you see. It doesn't decide how much blur there is.

Halfway down the mask, you get half of the full 24px blur laid over the sharp original. What you wanted there was one 12px blur. Slide along the fade to compare the two interpretations.

the mask ghosts
along the fade50%
A mask fades a blur in. It never makes it smaller.

At the two ends they agree. Everywhere in between, the masked version keeps every sharp edge, just fainter, with a glow around it. That's the ghosting.

Stack it up

The fix is a few smaller blurs instead of one large blur. Each layer starts a little lower than the one before and stays on to the bottom. The mask is only a tiny ramp at that seam; it is there to stop a hard line, not to fake a smaller blur. Where the layers overlap, the blurs pile up, so the further down you look, the softer it gets.

That is the trick in Jhey's demo: progressive blur is stacked blurs with a tiny bit of masking to smooth the seams. The blur does the actual work. The mask just makes the joins less obvious.

visible steps
drag ↔ layers 3

3

layers

24px

final blur

8.0–17.9

px per layer

100%

layer gap

layers3
final blur24px
layer gap100%
Each layer adds a little more blur, a little lower down.

With "show layers" on you can see where each one starts and how much it adds. Try tightening the layer gap too. Close starts can look better on a short fade because the layers overlap gently; wider starts spread the transition out, but can reveal steps. The readout below the card is the useful constraint: every layer is another full blur for the browser to draw. Two or three leave visible steps; around four or five they disappear. Past six, you are mostly paying for layers no one can see.

Blurs add up in squares

Stacking two blurs doesn't add their sizes. A 6px blur on top of an 8px one gives you 10px, not 14. Blurs combine like the sides of a right triangle1.

√(6² + 8²)two blurs10one blur

That matters when you pick the numbers. If the stack should grow evenly and land on exactly 24px, each layer adds the difference between where the stack is and where it should be next, measured in squares. That's where the odd-looking values in the demo come from. The gap control changes where those layers begin, not the blur math itself. Drag the controls and watch the first and last layer change independently. I picked this up from Jhey's demo2, which shows every layer's value as you drag.

Give it something to fade

There are two common ways to finish a fade. A progressive blur keeps the scene visible while it becomes softer. A white fade hides the scene and leaves a clean surface for text. They can occupy the same space, but they solve different problems.

Here is the blur version first. Use the layer control to see how several small blurs build one smooth edge.

blur keeps the scene

Osaka, after rain

A soft landing for the last few lines.

layers5
Five small blurs. One soft edge.

It isn't only for photos

The same idea works on more than photographs. Switch the card below to Text to see a soft gray-to-white surface, then turn on white fade to compare it with the blur.

soft focus

Osaka, after rain

A soft landing for the last few lines.

drag ↔ layers 3
layers3
Blur needs detail to soften.

A blur needs visual detail to soften, so it has less effect on a plain gradient. A white fade is better when text needs to sit on a clean background: it removes the image instead of making it fuzzy.

Move it

A still photo is the forgiving test. Motion is the real one. This list is deliberately rounded and fades at the edge so you can compare the three finishes without any square corners.

rows vanish

Strawberries

Sweet first, sharp after.

Watermelon

Mostly water, entirely summer.

Iced latte

Two shots over a lot of ice.

Katsu curry

Crunch first, then the sauce.

Side salad

The one you promise to order.

Strawberries

Sweet first, sharp after.

Watermelon

Mostly water, entirely summer.

Iced latte

Two shots over a lot of ice.

Katsu curry

Crunch first, then the sauce.

Side salad

The one you promise to order.

Strawberries

Sweet first, sharp after.

Watermelon

Mostly water, entirely summer.

Iced latte

Two shots over a lot of ice.

Katsu curry

Crunch first, then the sauce.

Side salad

The one you promise to order.

Strawberries

Sweet first, sharp after.

Watermelon

Mostly water, entirely summer.

Iced latte

Two shots over a lot of ice.

Katsu curry

Crunch first, then the sauce.

Side salad

The one you promise to order.

Rows should leave one soft step at a time.
TS
// How much blur each layer adds, so the stack grows evenly to `final`
function layerBlurs(final: number, count: number) {
  return Array.from({ length: count }, (_, i) => {
    const before = (final * i) / count;
    const after = (final * (i + 1)) / count;
    return Math.sqrt(after ** 2 - before ** 2);
  });
}

layerBlurs(24, 4); // [6, 10.4, 13.4, 15.9]

Here's that four-layer stack as plain HTML and CSS. Each layer carries its own blur, while its mask only eases in around the seam before staying solid. Put it over anything inside a parent with position: relative.

HTML
<div class="progressive-blur" aria-hidden="true">
  <div style="--blur: 6px; --from: 30%; --to: 36%"></div>
  <div style="--blur: 10.4px; --from: 50%; --to: 56%"></div>
  <div style="--blur: 13.4px; --from: 70%; --to: 76%"></div>
  <div style="--blur: 15.9px; --from: 90%; --to: 92%"></div>
</div>
CSS
.progressive-blur,
.progressive-blur > div {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.progressive-blur > div {
  backdrop-filter: blur(var(--blur));
  mask-image: linear-gradient(
    to bottom,
    transparent var(--from),
    black var(--to),
    black 100%
  );
}

Ease the ramp

There's one more thing to hide. A straight gradient has two creases: where it starts and where it stops. Your eye is very good at spotting a sudden change in slope, so a fade you meant to be invisible gets a faint line across it at each end3.

Easing fixes it. Instead of two stops, use a handful that follow an ease-in-out curve, so the fade starts and finishes gently.

hard edges
easing0%
Ease the ramp and the edges disappear.

This matters most for the dark tint people put under a blur to keep text readable. A blur hides its own creases. A flat tint shows every one. The same idea works on the blur too: run the layer values through an ease instead of a straight line, and the blur starts slower and finishes softer.

Keep in mind

  1. A mask fades a blur. It can't make it smaller, so one masked blur always ghosts.
  2. Stack a few layers, each starting lower and adding a little more.
  3. Blurs add up as √(a² + b²). Work out each layer's share from the total you want.
  4. Four or five layers is usually enough. Each one is a full blur to draw.
  5. Ease your gradients, masks and tints included, so they don't crease where they start and stop.

Resources

  1. Two Gaussian blurs in a row equal one blur whose size is the square root of the sum of their squares, and CSS blur() is a Gaussian. Wikipedia, Gaussian blur. Read more
  2. Jhey Tompkins' progressive blur demo, which builds the blur up on a curve you can drag. Read more
  3. The bright and dark lines you see there are called Mach bands. Read more