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.

What the compositor sees on the shared transformThe underlying value is the entrance offset; the question is what the hover effect does with it.What the compositor sees on the shared transformDefault (replace)Underlying entrance transform discardedElement jumps to the hover offsetEntrance progress is lost mid-flightVisible snap on hoveranimation-composition: addHover transform appended to the entrance listBoth offsets accumulate smoothlyNeither effect needs to know about the otherEffects layer cleanly
The underlying value is the entrance offset; the question is what the hover effect does with it.

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.

Picking the operation from the intentWhen two translate() effects should simply sum, add and accumulate agree — prefer add for readability.Picking the operation from the intentreplaceaddaccumulateOne effect mustoverride all othersCorrectLeaves residueLeaves residueIndependent offsets onone propertyLoses oneCorrectWorksIterating effect mustnot driftResets each cycleCompoundsCorrectTwo scale() effects,want raw sumsLoses oneMultiplies matricesCorrectTarget is a layoutpropertyStill costlyStill costlyStill costly
When two translate() effects should simply sum, add and accumulate agree — prefer add for readability.
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.

Layering an entrance and an interaction effectDeclaration order decides the underlying value, so keep the base effect first.Layering an entrance and an interaction effect1Declare the base entrance animation first, with the default replace operation.It establishes the underlying transform2Add the interaction animation second and set animation-composition: add on it.Its transform list is appended, not substituted3Keep both keyframe sets to transform and opacity only.Composition stays on the compositor thread4Gate the whole pair behind prefers-reduced-motion and settle to the resting transform.No layered motion when reduce is set
Declaration order decides the underlying value, so keep the base effect first.
/* 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

Reading a composed transform in DevTools

Composition makes the computed value less obvious than usual, because the rendered transform is the result of several effects that each contribute part of it. Two panels answer different questions.

The Elements > Styles pane shows the declared animations and their composite operations, which is where a missing animation-composition: add is visible — the property is listed on the animation, not on the element, so an effect that silently uses the default appears with no entry at all. The Animations panel shows the effects as separate rows with their own timings, which is how you confirm that both are actually running rather than one having replaced the other.

What neither shows directly is the arithmetic. If the composed result looks wrong, the fastest check is to disable one effect at a time in the Styles pane and read the remaining transform: add concatenates transform lists in effect order, so two translate functions produce a single visible offset that is their sum, while two scale functions multiply. Seeing each contribution in isolation makes it obvious which operation the intent actually needed — and it is usually accumulate where the values were expected to sum rather than compound.

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.