When filter and backdrop-filter Are Worth the Paint Cost

Part of Hardware-Accelerated Properties in Core CSS Animation Fundamentals.

The problem

filter: blur() looks like a compositor-safe property. It is applied by the GPU, it does not change layout, and a single static filter costs nothing per frame. Then someone animates the blur radius on a full-width hero and the frame rate halves on every device without a discrete graphics card.

The confusion is understandable, and the distinction matters: executing a filter on the GPU is not the same as animating it cheaply. One is a rasterisation step; the other is a rasterisation step repeated sixty times a second.

Root cause: changing the filter invalidates the raster

A compositor animation works because the layer’s pixels do not change — only the matrix that positions them, or the alpha that blends them. transform and opacity qualify precisely because neither alters what was painted.

A filter alters what was painted. Changing the blur radius means the previously rasterised tiles no longer represent the element, so they are discarded, the element is re-rastered with the new radius, and the result is re-uploaded to the GPU. The cost is proportional to the affected pixel area multiplied by the square of the device pixel ratio, and it is paid every frame the value changes — the same arithmetic that governs paint invalidation and texture memory.

backdrop-filter adds a second cost. It samples what is painted behind the element, which means the source region has to be read back before the filter runs. When anything underneath is moving — a scroll, another animation — that read cannot be cached, so a backdrop filter over an animated region re-samples every frame even if its own value is constant.

Per-frame cost of animating a filter, by affected areaMeasured on a mid-tier phone at device pixel ratio 3; the numbers scale with area, so a full-width hero is the worst case by a wide margin.Per-frame cost of animating a filter, by affected areawhole frame 16.7 msopacity cross-fade (any size)0.5 msblur on a 200x200 avatar2.4 msblur on a 360x240 card5.1 msbackdrop-filter on a sticky bar9.8 msblur on a 1280x600 hero22.3 ms
Measured on a mid-tier phone at device pixel ratio 3; the numbers scale with area, so a full-width hero is the worst case by a wide margin.

Decision matrix

Is the filter the information, or the decoration?Animate a filter only when the changing filter value is itself what the user is meant to perceive.Is the filter the information, or the decoration?Animate the filter?Cheaper equivalentBlur behind a modal,appearing onceNoCross-fade a pre-blurred layerHover blur on a cardimageNoTwo stacked images, animateopacityProgressive imagesharpening on loadYesThe change is the informationSaturation shift tosignal disabled stateNoStatic filter plus an opacitychangeDepth-of-field pull on aheroOnly if it is the subjectPre-blurred layer at twodepthsFrosted bar overscrolling contentStatic onlySolid translucent background
Animate a filter only when the changing filter value is itself what the user is meant to perceive.

The pattern behind the table: a filter is affordable when it is applied once and left alone, and expensive when its value changes. Almost every “animated blur” in a real interface is actually a transition between two known filter states, which means both states can be rasterised in advance.

Production code pattern

/* Intent: a card image that appears to blur on hover, without re-rastering
   anything. Two stacked copies, one pre-blurred, cross-faded on the GPU. */
.media { position: relative; isolation: isolate; contain: paint; }

.media__image,
.media__blurred {
  display: block;
  width: 100%;
  height: auto;
  grid-area: 1 / 1;
}

.media__blurred {
  position: absolute;
  inset: 0;
  filter: blur(10px) saturate(1.15);  /* rasterised once, never animated */
  opacity: 0;
  transition: opacity 200ms cubic-bezier(0, 0, 0.2, 1);
  will-change: opacity;               /* released below when idle */
}

.media:hover .media__blurred,
.media:focus-within .media__blurred { opacity: 1; }

/* Accessibility gate: a cross-fade is already minimal motion, but keep it
   short and make sure the hover state is not the only affordance. */
@media (prefers-reduced-motion: reduce) {
  .media__blurred { transition-duration: 80ms; }
}

Rendering Impact: opacity — composite only. The blurred copy is rasterised once when it is first painted; the hover animates alpha between two static textures, which the GPU blends without any repaint.

The trade-off is memory rather than time: two textures instead of one, for the duration of the promotion. On a card-sized element that is a fraction of a megabyte, which is the right trade against several milliseconds of main-thread work per frame. On a full-viewport hero it is not — and that is the case where the honest answer is to not animate the blur at all.

Converting an animated filter to a layered cross-fadeThe visual result is identical; only the frame cost changes.Converting an animated filter to a layered cross-fade1Identify the two filter states the animation moves between.Usually 'none' and one specific value2Render the filtered state as a second, absolutely positioned copy.It rasterises once, on first paint3Set its opacity to 0 and transition only opacity.The frame path is now composite-only4Add contain: paint to the wrapper so the blur cannot dirty neighbours.The repaint region is bounded5Promote only while the interaction is live, then release.Two textures held during hover, none at rest
The visual result is identical; only the frame cost changes.

Verification checklist

Constraints and trade-offs

  • The layered approach doubles texture memory for the element while both copies exist.
  • A pre-blurred copy cannot respond to a runtime-computed radius; if the radius is genuinely dynamic, the filter must be animated and capped to one element.
  • backdrop-filter cannot be pre-rasterised at all, because its input is whatever happens to be behind it; a translucent solid background is the only cheap substitute.
  • filter creates a containing block for fixed-position descendants, so adding one to a wrapper can move an unrelated element.
  • Stacking a blurred copy over the original needs isolation or an explicit stacking context, or a sibling with z-index can slide between them.

Frequently asked questions

Is filter GPU-accelerated?

The filter operation itself runs on the GPU in current engines, but changing the filter value invalidates the rasterised content, so the element must be re-rastered and re-uploaded each frame. Being GPU-executed is not the same as being free to animate.

Is backdrop-filter more expensive than filter?

Usually, because it samples everything painted behind the element. That backdrop is not stable while anything underneath is scrolling or animating, so the sampled region is re-read on frames where a plain filter would have been cached.

Can I animate a blur without animating the filter?

Yes, and it is the standard workaround. Paint the blurred version once on a pseudo-element or a second layer, then cross-fade opacity between the sharp and the blurred copy. The GPU blends two static textures, which is compositor-only.


The containing-block side effect nobody expects

A non-none filter does more than paint. It makes the element a containing block for descendants with position: fixed and position: absolute, and it establishes a new stacking context.

That is a spec behaviour, not a bug, and it is the reason a modal that worked yesterday suddenly positions itself inside a card today: someone added a hover blur to an ancestor. The modal’s position: fixed now resolves against the filtered element rather than the viewport, so it is clipped, mispositioned, or both.

The same applies to backdrop-filter, transform, perspective, will-change naming any of those, and contain: paint. What makes the filter case particularly confusing is that the side effect appears and disappears with the hover state, so the modal is correctly positioned until the user’s pointer happens to be over the card.

Two defences. First, put the filter on the smallest element that needs it — a pseudo-element or a dedicated overlay rather than the component root, which is the layered pattern above anyway. Second, render anything that must escape its ancestors in the top layer, using a <dialog> or the popover attribute, where containing blocks do not apply. Both are worth doing regardless of the performance argument, and together they make the paint optimisation and the layout robustness the same change.