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.addcombines 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
addon atransformconcatenates lists rather than summing every argument — fine for translations, but twoscale()effects multiply. Reach foraccumulatewhen you need component-wise sums.- Composition rescues nothing for layout-bound properties; a layered
widthanimation still forces a layout pass per frame. Refactor totransformbefore layering. - The composition list is positional. Supplying fewer keywords than
animation-namevalues silently repeats the list, which can applyaddto the base effect and cause drift. animation-compositionis unsupported on engines older than Chrome 112 / Firefox 115 / Safari 16, where everything degrades toreplace. Keep the top-priority animation independently usable.- Additive effects that iterate can accumulate without bound if written as absolute keyframes — use
accumulateand 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.
Related
- Animation Composition & Layering — the parent guide covering the full execution model and
@property - Keyframe Architecture & State Mapping — structuring the delta keyframes that additive layering depends on
- Avoiding Layout Thrashing in CSS Animations — why layered layout properties stay expensive