Layering Animations with animation-composition: add

Part of Animation Composition & Layering in Core CSS Animation Fundamentals.

The problem: the interactive effect eats the entrance effect

You ship a card that slides up as it enters the viewport, then add a hover lift for polish. The moment the pointer arrives, the card snaps down to its hover offset — the entrance translation is gone. Both effects animate transform, and only one transform value can render per frame. Wrapping the card in an extra element to give each effect its own transform works but bloats the DOM and adds a compositor layer. animation-composition: add fixes it in place: the hover translation is added to the reveal’s value instead of replacing it.

Root cause: replace is the default composite operation

When two animations target the same property, the browser builds an effect stack ordered by composite priority and resolves it with each animation’s animation-composition. The default is replace, which discards the underlying value — the entrance reveal — and substitutes the higher-priority effect — the hover lift. Nothing about your keyframes is wrong; the combination rule is throwing one of them away.

add changes that rule. For a transform, add concatenates the effect’s transform list onto the underlying list, so the two translateY contributions sum. A reveal resting at translateY(0) plus a lift of translateY(-8px) renders at translateY(-8px) — and mid-reveal, at translateY(12px), the same lift renders translateY(4px), so the two motions coexist smoothly through the whole entrance. Because the values being combined are transform and opacity, the arithmetic runs on the compositor thread with no added main-thread cost; composition never moves work off the GPU, it only decides how already-composited effects merge.

Decision matrix: replace vs add vs accumulate

Pick the composite operation from the shape of the effects you are combining.

Situation Operation Why
One effect should override all others (e.g. an exit that cancels everything) replace Last-wins is exactly the intent; underlying value must be discarded
Independent offsets on the same property (hover lift over scroll reveal) add Translations concatenate and sum; both effects stay visible
Repeating/iterating effect that must not drift each cycle accumulate Merges against the neutral element so iterations do not stack without bound
Two scale() or rotate() effects where you want raw component sums accumulate add multiplies matrices; accumulate sums the arguments
Two translate() effects and the numbers should simply add add For translation, add and accumulate are numerically identical — add reads clearer
Target property is layout-bound (width, top, margin) none — refactor Composition cannot make a layout property cheap; switch to transform first

Production pattern

The block below stacks a hover lift on top of a scroll-reveal entrance on a single element, with a support fallback and a reduced-motion gate. Read the inline comments — each marks a composition-critical decision.

/* Reveal is the base effect; hover lift is layered on top with add. */
.card {
  /* Entrance runs once; `both` holds start and end frames. */
  animation: reveal 500ms cubic-bezier(0.22, 1, 0.36, 1) both;
}

.card:hover,
.card:focus-within {
  /* Re-declare the reveal so its value is the underlying value,
     then layer the lift. One composition keyword per animation name. */
  animation: reveal 500ms cubic-bezier(0.22, 1, 0.36, 1) both,
             lift 200ms ease-out both;
  animation-composition: replace, add; /* reveal = base, lift = added */
}

@keyframes reveal {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Lift is a pure delta: it must describe an offset, never an absolute position,
   so that adding it onto the reveal produces the sum you expect. */
@keyframes lift {
  to { transform: translateY(-8px); }
}

/* Progressive enhancement: where composition is unsupported, the lift alone
   still reads correctly because it resolves to a small, sensible offset. */
@supports not (animation-composition: add) {
  .card:hover,
  .card:focus-within {
    animation: lift 200ms ease-out both;
  }
}

/* Accessibility gate: collapse the whole stack to a static resting state. */
@media (prefers-reduced-motion: reduce) {
  .card,
  .card:hover,
  .card:focus-within {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Rendering Impact: transform + opacity — composite only. add combines the two effects on the compositor thread with no layout or paint.

The same page in the parent guide walks through why the compositor resolves layered effects for free and shows the accumulate variant in full.

Verification checklist

Constraints and trade-offs

  • add on a transform concatenates lists rather than summing every argument — fine for translations, but two scale() effects multiply. Reach for accumulate when you need component-wise sums.
  • Composition rescues nothing for layout-bound properties; a layered width animation still forces a layout pass per frame. Refactor to transform before layering.
  • The composition list is positional. Supplying fewer keywords than animation-name values silently repeats the list, which can apply add to the base effect and cause drift.
  • animation-composition is unsupported on engines older than Chrome 112 / Firefox 115 / Safari 16, where everything degrades to replace. Keep the top-priority animation independently usable.
  • Additive effects that iterate can accumulate without bound if written as absolute keyframes — use accumulate and delta keyframes for looping motion.

Frequently asked questions

Why does my hover animation cancel my scroll reveal?

Both animations write transform, and the default composite operation is replace, so whichever animation has higher priority overwrites the other’s value entirely. Setting animation-composition: add on the hover animation makes its translation add onto the reveal’s underlying value instead of discarding it.

Do I set animation-composition per animation or once for the element?

It is a per-animation longhand that accepts a comma-separated list aligned with animation-name. Give each animation its own keyword; if you supply fewer keywords than names, the list is repeated to fill, which can apply add to the wrong effect.