Composite Modes in the Web Animations API
Part of CSS Animation Composition & Layering in Core CSS Animation Fundamentals.
The problem
A product card has a CSS entrance animation. When the user adds the item to the cart, a script plays a quick “bump” with element.animate(). If the bump starts while the entrance is still running, the card jumps to the bump’s keyframe values, ignoring where the entrance had it — and then snaps back into the entrance when the bump ends.
The CSS property animation-composition solves this for declarative animations. Script-created effects have their own, richer set of controls: composite, per-keyframe composite, and iterationComposite.
Root cause analysis: the effect stack
Every animation targeting an element property sits in an ordered effect stack. CSS animations come first in the order their names appear, then script animations in the order they were created. The browser starts from the base computed value and walks up the stack: each effect combines its value with the result so far according to its composite operation.
With the default replace, the top effect simply wins. add and accumulate combine instead, with the same semantics as in CSS — appending lists versus summing matching functions, described in accumulate versus add.
The API adds two capabilities CSS lacks. Per-keyframe composite lets a single keyframe replace while its neighbours add, which is useful for an effect that should end at an absolute value. iterationComposite: 'accumulate' makes each iteration of a looping effect start from where the previous one ended, so a repeating nudge of translateX(20px) walks the element 20px further every cycle.
Step-by-step resolution
Production code pattern
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
// Bump: a short scale delta layered over whatever the card is doing.
function bump(card) {
if (reduce.matches) return card.animate(
[{ opacity: 0.7 }, { opacity: 1 }], { duration: 150 } // gentle, no scale
);
return card.animate(
[
{ transform: 'scale(1)' },
{ transform: 'scale(1.08)', offset: 0.35 },
{ transform: 'scale(1)' },
],
{ duration: 320, easing: 'cubic-bezier(0.3, 0.7, 0.2, 1)', composite: 'add' }
);
}
// Stepper: each iteration continues from where the previous one ended,
// because accumulate adds the final keyframe value once per completed iteration.
function advanceProgressDots(track, steps) {
if (reduce.matches) {
track.style.translate = `${steps * 12}px 0`; // jump straight to the end
return;
}
return track.animate(
[
{ translate: '0 0' },
{ translate: '12px 0' }, // one step per iteration
],
{ duration: 140, iterations: steps, easing: 'ease-out',
iterationComposite: 'accumulate', fill: 'forwards' }
);
}
.card {
animation: card-in 500ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes card-in {
from { transform: translateY(16px) scale(0.96); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.card { animation: none; }
}
Rendering Impact: composite. All effects animate
transform,translateandopacity, so the combined value is still sampled on the compositor; composite modes change the arithmetic, not the thread.
Writing the bump so its first and last keyframes are identity is what makes add safe. An additive effect that ends at scale(1.08) would leave the card enlarged by 8% until the bump is cancelled; one that ends at scale(1) contributes nothing once finished, even with a fill.
Verification checklist
Constraints and trade-offs
iterationComposite: 'accumulate'adds the final keyframe value once per completed iteration, so an effect whose last keyframe is identity does not grow at all.- Effect order is creation order; recreating a CSS animation can move it relative to script effects.
commitStyles()writes the fully combined value as an inline style, which then becomes the base for every later effect.- Composite modes are ignored by engines that do not support them, which fall back to
replace. - Deeply layered effects are hard to debug; keep the stack to two or three layers per property.
When to reach for composite modes instead of CSS
animation-composition covers the declarative case: two CSS animations known in advance, sharing a property. Script composite modes earn their place when the second effect is decided at runtime — a bump whose size depends on how many items were added, a shake whose distance depends on how wrong the input was, a highlight that follows data rather than a class.
They are also the only way to layer onto a transition. Transitions have no animation-composition equivalent, so a hover transition on transform and a script bump on transform would otherwise compete. An additive script effect sits on top of whatever the transition is producing at that moment, which is exactly the “respond on top of the current state” behaviour interactions want.
Two practical rules keep script composition maintainable. Name effects with the id option so they are identifiable in getAnimations() output and in the Animations drawer. And cancel additive effects that are superseded — two overlapping bumps both add, so rapid clicks can stack into a much larger scale than designed. Keeping a reference to the previous bump and calling cancel() or finish() before starting the next one bounds the result.
Frequently asked questions
Where do script animations sit relative to CSS animations?
Above them. CSS animations are ordered by their position in the animation-name list, and animations created with element.animate() are placed after all CSS animations in creation order.
What does iterationComposite do?
With accumulate, each iteration adds the final keyframe value once for every completed iteration, so an effect that moves 12px per iteration continues from 12px, then 24px. The default, replace, repeats the same values each time.
Can one keyframe replace while others add?
Yes. A keyframe object can carry its own composite property, which overrides the effect-level composite for the interval starting at that keyframe.
Related
- CSS Animation Composition & Layering — the parent topic
- commitStyles() & Persisting End State — turning a combined value into base style
- element.animate() vs CSS Keyframes — when script-created effects are the right tool