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.

Separate filter declarations against one composed declarationThe composed version keeps every state and a fixed order.Separate filter declarations against one composed declarationOne filter per statehover: brightness(1.15)loading: blur(6px)Hovering a loading tile removes the blurStates overwrite each otherCustom properties, one filterfilter: brightness(var(--b)) grayscale(var(--g))blur(var(--blur))Each state sets only its own variableOrder never changesStates stack predictably
The composed version keeps every state and a fixed order.

Step-by-step resolution

Refactoring colliding filter statesMove the amounts into variables and the order into one declaration.Refactoring colliding filter states1Register --tile-brightness, --tile-grayscale and --tile-blur with @property.Typed values interpolate smoothly2Write a single filter declaration using all three in a fixed order.Identity values keep unused effects invisible3Change each state rule to set only its own custom property.States no longer overwrite each other4Transition the custom properties rather than filter.Each effect keeps its own duration5Check a combined state, such as hover while loading and disabled.All three effects visible at once6Drop animated blur for reduced-motion users.
Move the amounts into variables and the order into one declaration.

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.

Filter paint time per frame on a 480px tileMid-range phone. Blur dominates; colour filters are cheap.Filter paint time per frame on a 480px tilebrightness only0.6 msgrayscale only0.7 msblur(6px) only4.9 msAll three, one declaration5.4 msTwo competing blur chains (add)9.6 ms
Mid-range phone. Blur dominates; colour filters are cheap.

Verification checklist

Constraints and trade-offs

  • Custom-property transitions require @property support; without it the variables change discretely.
  • An always-present filter creates a stacking context, which can change z-order of positioned children.
  • backdrop-filter has 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.