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.
Decision matrix
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.
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-filtercannot be pre-rasterised at all, because its input is whatever happens to be behind it; a translucent solid background is the only cheap substitute.filtercreates 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
isolationor an explicit stacking context, or a sibling withz-indexcan 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.
Related
- Hardware-Accelerated Properties — the parent guide and the full property-to-tier table
- Paint Invalidation & Repaint Boundaries — bounding the region a filter can dirty
- GPU Memory & Texture Management — the memory side of holding a second rasterised copy