# Progressive blur in CSS

> How to build a progressive blur overlay with stacked backdrop-filter layers and gradient masks — the gradual blur Apple and Airbnb use to make text readable over an image.

Published 2025-12-24 by Jaber El Ferkh. Tags: css, ui, frontend, effects, backdrop-filter.
Canonical page: https://jaberelferkh.com/topics/progressive-blur-effect

---
Solid black overlays are a thing of the past. **Progressive blur** — a blur that starts at zero and deepens toward one edge — is the technique Apple and Airbnb use to keep text readable over a photograph without throwing a grey sheet across the whole image.

::progressive-blur-demo
::

::divider
::

## The problem with a single overlay

You have a photo and you need to put text on it. The reflex is a dark gradient:

```css
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.7));
```

It works, and it costs you the picture. Everything behind the text turns to mud. You dimmed the image instead of separating the text from it.

The second reflex is one `backdrop-filter`:

```css
backdrop-filter: blur(12px);
```

That fails differently. A single blur layer has a hard top edge — the image is sharp, then abruptly it is not. The seam is visible, and it looks like a mistake rather than a finish.

::spacer
::

## What progressive blur actually is

The effect people recognise as "progressive blur" is not one blur that ramps up. CSS has no such thing: `backdrop-filter: blur()` takes a single radius and applies it uniformly.

So you fake it. You **stack several blur layers of different strengths and different heights**, and mask each one so its top edge fades to nothing. Where the layers overlap, the blur accumulates. The result reads as a smooth gradient of blur, built from three flat steps.

The key relationship is inverted, and it is the part people get wrong:

> **The tallest layer carries the weakest blur. The shortest layer carries the strongest.**

Stack them the other way and you get a hard band of blur floating in the middle of the image.

::divider
::

## The three layers

Here is the structure. Three absolutely positioned siblings, all pinned to the bottom:

```html
<div class="overlay">
  <div class="blur-strong"></div>
  <div class="blur-medium"></div>
  <div class="blur-light"></div>
</div>
```

The **light** layer is tallest and barely blurs. It starts the transition high up the image, so gently you cannot point to where it begins:

```css
.blur-light {
  position: absolute;
  inset: auto 0 0 0;
  height: 75%;
  backdrop-filter: blur(2px) saturate(1.05);
  -webkit-backdrop-filter: blur(2px) saturate(1.05);
  mask: linear-gradient(to bottom, transparent 0%, black 30%, black 100%);
  -webkit-mask: linear-gradient(to bottom, transparent 0%, black 30%, black 100%);
}
```

The **medium** layer covers just over half the card at three times the radius:

```css
.blur-medium {
  height: 55%;
  backdrop-filter: blur(6px) saturate(1.1);
  mask: linear-gradient(to bottom, transparent 0%, black 30%, black 100%);
}
```

The **strong** layer is the shortest and does the real work, sitting directly behind the text:

```css
.blur-strong {
  height: 35%;
  backdrop-filter: blur(12px) saturate(1.2);
  mask: linear-gradient(to bottom, transparent 0%, black 20%, black 100%);
}
```

At the very bottom all three are active: `2px + 6px + 12px` of accumulated blur. At 75% up the card, only the light layer applies. Between those points the blur grows in steps small enough that the eye reads them as continuous.

::spacer{size="small"}
::

## Why each layer needs a mask

The mask is not decoration — it is the whole trick.

Without it, every layer announces its own top edge, and you have traded one visible seam for three. `mask: linear-gradient(to bottom, transparent 0%, black 20%, black 100%)` makes each layer fade in from nothing across its first 20–30%, so its edge dissolves instead of landing.

Note the strong layer feathers over 20% while the other two use 30%. The strong layer is the shortest, so 20% of its height is a shorter distance in pixels — it needs to reach full strength quickly to sit behind the text, but not so quickly that it shows a line.

::spacer{size="small"}
::

## The saturation detail

Each layer also nudges saturation up:

```css
backdrop-filter: blur(12px) saturate(1.2);
```

Blurring averages neighbouring pixels together, and averaging colour pulls it toward grey. A blurred photo looks slightly washed out. Scaling saturation back up — `1.05`, `1.1`, `1.2`, tracking the blur radius — returns the colour the blur took. Remove it and the effect still works, but the bottom of the card looks faintly dead.

::divider
::

## Containing the blur

`backdrop-filter` samples everything painted behind the element, and it does not respect the parent's rounded corners on its own. On a card with `border-radius`, the blur will square off the bottom corners.

Each layer has to carry the radius itself:

```css
border-bottom-left-radius: var(--card-radius, 16px);
border-bottom-right-radius: var(--card-radius, 16px);
```

`overflow: hidden` on the parent is the more obvious fix, and it works — until something inside needs to escape the bounds, at which point you have traded one problem for another.

::spacer{size="small"}
::

## The fallback

`backdrop-filter` has broad support now, but it is expensive, and it is the first thing to go in a reduced-capability context. Feature-detect and fall back to the honest gradient:

```css
@supports not (backdrop-filter: blur(8px)) {
  .blur-strong {
    background: linear-gradient(
      180deg,
      rgba(0, 0, 0, 0) 0%,
      rgba(0, 0, 0, 0.1) 50%,
      rgba(0, 0, 0, 0.3) 100%
    );
  }
}
```

The blur is a refinement. Legible text is not. If the blur cannot render, the gradient still has to guarantee contrast.

Each layer also keeps a faint dark gradient of its own on top of the blur — `rgba(0, 0, 0, 0.1)` at most. Blur alone does not guarantee contrast: blur a bright white sky and you get a bright white background with white text on it. The tint is what makes the result safe for any photograph.

::divider
::

## What to tune

Three numbers decide how the effect reads:

- **Heights** control where the transition starts. Taller layers, gentler effect.
- **Radii** control the depth. Keep them roughly geometric — `2 / 6 / 12` — so each step is a clear jump.
- **Mask stops** control the softness of each edge. Below about 20% they start to show.

On small screens, grow every layer — `40% / 60% / 80%` — because text occupies proportionally more of the card and needs more runway behind it.

Three layers is the sweet spot. Two is visibly stepped. Four costs another full-surface `backdrop-filter` pass for a difference nobody can see.
