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.

The effect stack for the card's transformValues are combined bottom to top; composite mode decides how each layer treats what is below it.The effect stack for the card's transformBump (element.animate, add)Created last: highest in the stackEntrance (CSS animation)CSS animations sit below script onesBase computed styletransform: none
Values are combined bottom to top; composite mode decides how each layer treats what is below it.

Step-by-step resolution

Adding a script effect that respects running CSS motionThe CSS animation stays untouched; only the script effect's options change.Adding a script effect that respects running CSS motion1Leave the CSS entrance animation exactly as it is.It stays at the bottom of the stack2Create the bump with element.animate() and composite: 'add'.It stacks on the entrance's current value3Write the bump keyframes as deltas from identity, ending at identity.No residual offset when it finishes4For a progressive effect, set iterations and iterationComposite: 'accumulate'.Each loop builds on the last5Use commitStyles() only if the combined value should become the base style.Avoids stray inline styles6Skip or shorten the effect when reduced motion is preferred.
The CSS animation stays untouched; only the script effect's options change.

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, translate and opacity, 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.

The stepper with iterationComposite: accumulateEach 140 ms iteration starts where the last ended: 0 to 12 px, 12 to 24 px, 24 to 36 px.The stepper with iterationComposite: accumulateIteration 1 (0 to 12px)stephold420 msIteration 2 (12 to 24px)waitstephold420 msIteration 3 (24 to 36px)waitstep420 msstepholdwait
Each 140 ms iteration starts where the last ended: 0 to 12 px, 12 to 24 px, 24 to 36 px.

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.