CSS Animation Composition & Layering

Part of Core CSS Animation Fundamentals.

Composition is the rule the browser applies when more than one animation, or an animation and an underlying style, target the same property on the same element at the same instant. By default a CSS animation replaces whatever value sat beneath it, so the last effect declared wins and every other contribution is silently discarded. The animation-composition property changes that resolution: with add and accumulate the effects combine instead of overwrite, letting a scroll reveal, a hover lift, and an idle float coexist on a single transform without any of them clobbering the others. This page explains how the compositor resolves layered effects, when each composite operation is correct, and how registered custom properties turn a stack of independent motions into a single composable system.


What composition solves

The classic failure of CSS motion is the shared-property collision. A card enters the viewport with transform: translateY(24px) → 0, and the same card lifts on hover with transform: translateY(-8px). Both rules write transform. The cascade — or, once both are animations, the composite order — picks exactly one value per frame, and the effect you did not pick vanishes. Developers usually work around this by nesting wrapper elements, one per effect, so each animation owns its own transform. That works, but it inflates the DOM, multiplies compositor layers, and makes the relationship between the effects invisible in the markup.

animation-composition removes the need for those wrappers. It is an animation longhand, set per animation (and accepting a comma-separated list to match animation-name), that declares how each animation’s output should be combined with the value beneath it — the underlying value, which may be the base style or the result of a lower-priority animation. Because the combination happens as the animation is sampled, not through extra DOM, the effects stay on one element and one layer.


Execution model: how the compositor resolves layered effects

Every animation produces, for each property it touches, an effect value on every frame. The browser stacks these effects into an effect stack ordered by composite priority: the underlying style sits at the bottom, and animations are layered on top in their animation-name order (later names have higher priority). Composition walks that stack from the bottom up, folding each effect into a running result using the effect’s animation-composition operation.

  • replace (the default) throws away the running result and substitutes the effect value outright. With a stack of replace animations, only the top one is ever visible — the historical, and still standard, last-animation-wins behaviour.
  • add appends the effect to the running result. For a <transform-list> the two lists are concatenated, so the resulting matrix is the product of the individual matrices applied in order. For a scalar like opacity or a registered numeric custom property, the values simply sum.
  • accumulate merges the effect into the running result component by component. Matching transform functions have their arguments combined against the function’s neutral element, so two scale() contributions accumulate as (a − 1) + (b − 1) + 1 rather than multiplying.

For transform and opacity this resolution is part of the compositor-thread sampling that already runs the animation — keeping motion on hardware-accelerated properties means composition costs nothing extra on the main thread. Invalidation only fires when a layered property is itself layout- or paint-bound: composing two width animations still forces a layout pass per frame, because the expense lives in the property, not in the arithmetic that combines it. The composite operation never rescues a badly chosen property; it only decides how already-scheduled effects merge.


Property and API reference

Property / value Values Compositing tier Notes
animation-composition replace | add | accumulate Composite (for transform/opacity) Per-animation longhand; accepts a comma list matching animation-name
animation-composition: replace keyword Composite Default; effect overwrites the underlying value (last animation wins)
animation-composition: add keyword Composite Concatenates transform lists / sums scalars onto the underlying value
animation-composition: accumulate keyword Composite Merges matching functions component-wise against their neutral element
@property syntax, inherits, initial-value Style → Composite Registers a custom property so it interpolates instead of snapping
@property syntax: "<length>" descriptor Composite Numeric syntax makes the property animatable and additive
@property syntax: "<number>" descriptor Composite Use for unitless multipliers, scales, and opacity-like values
var(--x) inside @keyframes reference Style Data-driven distance/angle per instance; resolved at style time

Diagram: replace versus add on one element

The clearest way to see composition is to give one element two transform animations — a scroll reveal at translateY(24px) and a hover lift at translateY(-8px) — and watch how each composite operation resolves them. Under the default replace, the hover animation overwrites the reveal and the entrance offset is lost. Under add, the two translations sum and both effects survive.

animation-composition: replace versus add Two columns. The left column shows replace, where a hover lift animation overwrites a scroll reveal so the reveal offset is discarded. The right column shows add, where the reveal and hover translations sum into a combined result of translateY 16 pixels. animation-composition: replace animation-composition: add Reveal effect translateY(24px) Hover effect (top of stack) translateY(-8px) Rendered: translateY(-8px) reveal discarded — last wins Reveal effect translateY(24px) Hover effect (added) translateY(-8px) Rendered: translateY(16px) 24 + (-8) — both effects survive Same two animations, same element — only the composite operation differs.

Annotated examples

1. Stacking a hover lift onto a scroll reveal

Intent: run an entrance animation and an interactive lift on the same transform, letting them add instead of overwrite.

/* Both animations target transform; add makes them stack rather than clobber. */
.card {
  animation: reveal 500ms cubic-bezier(0.22, 1, 0.36, 1) both;
}

.card:hover {
  /* Second animation layered on top of the reveal's underlying value */
  animation: reveal 500ms cubic-bezier(0.22, 1, 0.36, 1) both,
             lift 200ms ease-out both;
  animation-composition: replace, add;
}

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

@keyframes lift {
  to { transform: translateY(-8px); }
}

@media (prefers-reduced-motion: reduce) {
  .card,
  .card:hover {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Rendering Impact: transform + opacity — composite only. Composition adds no main-thread work.

2. Additive keyframes with a registered custom property

Intent: drive an idle float and a press-scale from independent registered properties so each stays composable and interpolates smoothly.

/* Register the properties so the compositor can interpolate them. */
@property --float-y {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}
@property --press-scale {
  syntax: "<number>";
  inherits: false;
  initial-value: 1;
}

.token {
  transform: translateY(var(--float-y)) scale(var(--press-scale));
  animation: float 3s ease-in-out infinite;
}

.token:active {
  animation: press 120ms ease-out both;
}

@keyframes float {
  50% { --float-y: -6px; }
}

@keyframes press {
  to { --press-scale: 0.96; }
}

@media (prefers-reduced-motion: reduce) {
  .token,
  .token:active {
    animation: none;
    --float-y: 0px;
    --press-scale: 1;
  }
}

Rendering Impact: registered <length>/<number> custom properties feeding transform — composite only; no layout or paint per frame.

3. Accumulate for component-wise scale merging

Intent: show why add and accumulate diverge on scale, and pick accumulate when you want raw component sums rather than matrix multiplication.

/* Two scale animations on one element resolved by accumulate. */
.badge {
  animation: pulse 1.6s ease-in-out infinite,
             emphasise 400ms ease-out both;
  /* pulse: replace (base), emphasise: accumulate onto it */
  animation-composition: replace, accumulate;
}

@keyframes pulse {
  50% { transform: scale(1.05); }
}

@keyframes emphasise {
  to { transform: scale(1.1); }
}
/* accumulate merges the scales as (1.05-1)+(1.1-1)+1 = 1.15,
   whereas add would concatenate and multiply to 1.155. */

@media (prefers-reduced-motion: reduce) {
  .badge {
    animation: none;
    transform: none;
  }
}

Rendering Impact: transform: scale() — composite only. Choice of add vs accumulate changes the numeric result, not the rendering thread.

For the day-to-day case — stacking one interactive effect on one entrance effect — the practical walkthrough lives in layering animations with animation-composition: add. For structuring the keyframe timelines those effects build on, see keyframe architecture and state mapping.


DevTools workflow

Chrome’s tooling exposes composed animations directly, which is the fastest way to confirm effects are stacking rather than overwriting.

Chrome DevTools — Animations panel

  1. Open DevTools (F12), then the three-dot menu → More toolsAnimations.
  2. Trigger the interaction (hover, scroll, press) so every animation on the element is running.
  3. The panel groups animations by element. An element carrying two transform animations shows two timeline tracks — if you only see one contribute to the rendered position, composition is set to replace.
  4. Scrub the scrubber and watch the element in the viewport. With add, the element’s offset should reflect the sum of both tracks at each point.

Chrome DevTools — Elements → Styles / Computed

  1. Select the element and read the Computed pane. animation-composition appears in the animation longhands.
  2. Confirm the comma count matches animation-name; a mismatch means the list is being repeated to fill, which can apply add to the wrong animation.

Chrome DevTools — Performance panel

  1. Record while the layered animations play.
  2. Verify the Compositor thread advances frames while the Main thread stays idle. Composition of transform/opacity should never introduce Recalculate Style or Layout events on the main thread.

Failure modes and fixes

Problem: Adding animation-composition: add makes the element drift progressively further each time the animation restarts. Root cause: The keyframes are absolute (from/to with full positions) rather than deltas, so each run adds a fresh full offset onto the accumulated underlying value. Fix: Write additive animations as small deltas from the neutral value, and prefer accumulate for iterating animations so repeated iterations merge against the neutral element instead of stacking without bound.


Problem: A custom-property animation jumps straight to its end value instead of interpolating. Root cause: The property is unregistered, so it is typed as a string and cannot be interpolated — only swapped at keyframe boundaries. Fix: Declare it with @property and a numeric syntax (<length>, <number>, <angle>, <percentage>). Registration is what unlocks smooth, composable interpolation.


Problem: add produces a subtly wrong scale or rotation compared with what you expected from summing the numbers. Root cause: add concatenates transform lists, so functions multiply as matrices rather than summing their arguments. Fix: Use accumulate when you want component-wise argument sums, or split the effects onto separate registered custom properties and compose them in one transform declaration.


Problem: The layered effect works in Chrome but the entrance offset disappears in an older browser. Root cause: animation-composition is unsupported there, so every animation falls back to replace and the top animation wins. Fix: Treat add/accumulate as progressive enhancement. Ensure the single top-most animation still renders an acceptable state on its own, or feature-detect with CSS.supports('animation-composition', 'add').


Accessibility and reduced-motion

Layering compounds motion, and compounding is exactly what motion-sensitive users need less of. Two effects that each read as gentle can, when added, produce a larger net displacement or a busier composite rhythm than either alone. The safe default is to collapse the entire stack to a single static end state under prefers-reduced-motion: reduce, resetting both transform and any registered custom properties to their neutral values so no residual offset survives.

@media (prefers-reduced-motion: reduce) {
  .card,
  .token,
  .badge {
    animation: none !important;
    transform: none;
    --float-y: 0px;
    --press-scale: 1;
  }
}

Where a layered effect is functional rather than decorative — say an opacity change that signals state — keep only that one contribution and drop the spatial layers, following the patterns in the accessible motion architecture reference. Verify with Chrome DevTools → RenderingEmulate CSS media feature prefers-reduced-motion: with the flag on, the element should settle at its resting position with no compounded movement.


Frequently asked questions

What does animation-composition: add actually do to a transform?

It appends each animation’s transform to the underlying transform list rather than replacing it. Two animations that both animate translateY are concatenated, so their translations sum. The default value, replace, discards the underlying value and lets the last-declared animation win.

What is the difference between add and accumulate?

For a transform list, add concatenates the two lists so the matrices multiply in sequence, while accumulate merges matching functions component by component so their arguments sum into a single function. For a plain translateX the numeric result is identical; for scale and rotate the two operations diverge because concatenation multiplies whereas accumulation adds the raw components.

Is animation-composition compositor-safe?

Composition of transform and opacity is resolved as part of the same compositor-thread work that runs the animations themselves, so it adds no main-thread cost. Composition only becomes expensive when the layered properties are layout- or paint-bound, because the browser must recompute those phases every frame regardless of how the values are combined.

Why do I need @property for custom-property animations?

An unregistered custom property is treated as an unanimatable string, so it snaps between keyframe values instead of interpolating. Registering it with @property and a numeric syntax such as <length> or <number> tells the engine how to interpolate it smoothly, which is what makes composable, variable-driven motion possible.

Which browsers support animation-composition?

animation-composition ships in Chrome 112+, Edge 112+, Firefox 115+, and Safari 16+. Because replace is the default and matches long-standing behaviour, code that omits the property degrades to last-animation-wins on older engines, so treat add as a progressive enhancement and design the single-animation fallback to remain usable.