Layering filter Animations Without Overwrites
Part of CSS Animation Composition & Layering in Core CSS Animation Fundamentals.
The problem
A media tile uses filter for three independent states. Hover brightens it. While its thumbnail loads it is blurred. When the item is unavailable it is greyed out. Each state was written separately, each with its own filter declaration and transition, and they collide: hovering a loading tile removes its blur, and a disabled tile that finishes loading suddenly regains its colour.
This is the same overwrite problem as transform — one property, many effects — with an extra complication: filter functions are applied in order, and their order changes the picture.
Root cause analysis: one property, one winning value
filter is a single property whose value is a list of functions. When two rules set it, the cascade picks one list and discards the other. When two animations target it, the default composition replaces the lower value with the higher one. Neither mechanism knows that blur() and grayscale() could coexist.
Order matters. grayscale(1) brightness(1.3) brightens a grey image; brightness(1.3) grayscale(1) desaturates a brightened one. For these two the difference is small, but drop-shadow() before blur() blurs the shadow, and opacity() before drop-shadow() changes the shadow’s strength.
Interpolation needs matching lists. Transitioning between filter: blur(4px) and filter: brightness(1.2) works only because the browser pads missing functions with their identity values — but only when one list is a prefix of the other or the functions line up. Otherwise the transition can fall back to a discrete swap.
Composition helps only partly. animation-composition: add appends filter lists, which keeps both effects but can duplicate functions and changes order depending on which animation ran first. A more predictable approach is to keep one declaration with a fixed function order and animate the amounts.
Step-by-step resolution
Production code pattern
@property --tile-brightness { syntax: "<number>"; inherits: false; initial-value: 1; }
@property --tile-grayscale { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --tile-blur { syntax: "<length>"; inherits: false; initial-value: 0px; }
.tile {
/* One declaration, one fixed order: brightness, then grayscale, then blur. */
filter:
brightness(var(--tile-brightness))
grayscale(var(--tile-grayscale))
blur(var(--tile-blur));
transition:
--tile-brightness 160ms ease-out,
--tile-grayscale 240ms linear,
--tile-blur 300ms ease-out;
}
.tile:hover { --tile-brightness: 1.12; }
.tile[aria-busy="true"] { --tile-blur: 6px; }
.tile[aria-disabled="true"] { --tile-grayscale: 1; }
@media (prefers-reduced-motion: reduce) {
.tile { transition: --tile-grayscale 0s, --tile-brightness 0s, --tile-blur 0s; }
.tile[aria-busy="true"] { --tile-blur: 0px; opacity: 0.6; } /* static loading cue */
}
Rendering Impact: paint. Filters are re-rasterised whenever their amounts change; on a promoted layer some filter animations can be composited, but blur on large elements is expensive either way. Merging three effects into one declaration means one filter chain per frame instead of competing chains.
With every amount at its identity value — brightness 1, grayscale 0, blur 0px — the filter has no visible effect, but it still exists. A filter other than none creates a stacking context and a containing block for fixed-position descendants. If that matters, set filter: none on tiles that never use any of the effects.
Verification checklist
Constraints and trade-offs
- Custom-property transitions require
@propertysupport; without it the variables change discretely. - An always-present filter creates a stacking context, which can change z-order of positioned children.
backdrop-filterhas the same overwrite problem and benefits from the same pattern.- Blur cost scales with radius and element area; animate blur only on small elements or briefly.
- Design tools often export filter stacks in a different order; reconcile them with the canonical order.
Applying the same pattern to backdrop-filter and shadows
The overwrite problem is not unique to filter. backdrop-filter on a frosted panel collides the same way when a scrolled state adds saturation and a modal-open state adds extra blur. The same fix applies: one declaration in a fixed order, amounts in registered custom properties, each state setting only its own variable.
box-shadow is list-valued too, and teams often stack an elevation shadow, a focus ring drawn as a spread shadow and an error glow. A single box-shadow declaration built from three custom properties — each a full shadow, with a transparent default — keeps all three available. Shadow interpolation requires the same number of shadows in both lists, so the transparent defaults are not just tidy, they are what makes the transition possible. The paint cost of animating those shadows is a separate problem, covered in replacing box-shadow animation with layered opacity.
For any list-valued property, write down the canonical order once in a comment beside the declaration. Future states then only add a variable, and nobody reintroduces a competing declaration.
Frequently asked questions
Why does my hover remove the loading blur?
Both states set the filter property, and only one value can win. Build filter from custom properties so each state changes only its own amount.
Does the order of filter functions matter?
Yes. Functions apply in sequence, so drop-shadow before blur blurs the shadow, and opacity before drop-shadow weakens it. Pick one order and use it everywhere.
Can I use animation-composition instead?
You can use add to keep both lists, but the resulting order depends on which animation is lower in the stack and functions can be duplicated. A single declaration with custom properties is more predictable.
Related
- CSS Animation Composition & Layering — the parent topic
- When filter and backdrop-filter Are Worth the Paint — the cost side of filter animation
- Registering Custom Properties with @property — the typed variables this pattern relies on