animation-composition with Registered Custom Properties

Part of CSS Animation Composition & Layering in Core CSS Animation Fundamentals.

The problem

A floating action button bobs gently while idle, lifts when hovered and rises further when a snackbar appears beneath it. All three effects move it vertically. Written as three translate animations they overwrite each other; written with animation-composition: add on translate they work, but every other property of the transform has to be kept out of the way.

A cleaner model is a single number — the button’s vertical offset — to which each effect contributes. Custom properties can hold that number. Whether several animations can add up into it depends entirely on whether the property is registered.

Root cause analysis: unregistered variables are strings

An unregistered custom property is a sequence of tokens. The browser does not know --lift: 8px is a length, so it cannot interpolate it and cannot add two values together. Animating it produces a discrete flip at 50%, and any composition mode other than replace has nothing to combine.

Registering the property with @property and a syntax such as <length> changes that. The value is now a typed length: it interpolates smoothly, and animation-composition: add sums the underlying value and the effect value — 4px plus 8px is 12px. For numeric syntaxes add and accumulate behave identically; the list-versus-sum distinction in accumulate versus add does not apply to single numbers.

The property is then consumed once, in the real style: translate: 0 calc(-1 * var(--fab-lift)). Every effect speaks the same unit, and the transform stays simple.

Three effects summed into one offsetEach animation contributes a delta; the consumer reads the total once.Three effects summed into one offsetIdle bob0 to 3pxHover lift+6pxSnackbar+56px--fab-liftsumtranslatecomposite
Each animation contributes a delta; the consumer reads the total once.

Step-by-step resolution

Building a composable offsetRegister once, consume once, contribute from anywhere.Building a composable offset1Register --fab-lift as <length> with initial-value 0px and inherits: false.Typed, interpolable, summable2Set translate: 0 calc(-1 * var(--fab-lift)) on the button.Single consumer of the total3Write the bob, hover and snackbar keyframes as contributions from 0px.Each effect is a delta4List the animations together and set animation-composition: replace, add, add.Contributions sum in stack order5Inspect the computed --fab-lift while effects overlap.Confirms the arithmetic6Remove the bob and shorten the others under reduced motion.
Register once, consume once, contribute from anywhere.

Production code pattern

@property --fab-lift {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

.fab {
  translate: 0 calc(-1 * var(--fab-lift));
  animation: fab-bob 2.4s ease-in-out infinite alternate;
}

.fab:hover {
  animation:
    fab-bob 2.4s ease-in-out infinite alternate,
    fab-hover 180ms ease-out forwards;
  animation-composition: replace, add;
}

/* A snackbar is visible: rise above it, on top of whatever else is happening. */
.has-snackbar .fab,
.has-snackbar .fab:hover {
  animation:
    fab-bob 2.4s ease-in-out infinite alternate,
    fab-hover 180ms ease-out forwards paused,
    fab-clear 260ms cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
  animation-composition: replace, add, add;
}
.has-snackbar .fab:hover { animation-play-state: running, running, running; }

@keyframes fab-bob   { from { --fab-lift: 0px; } to { --fab-lift: 3px; } }
@keyframes fab-hover { from { --fab-lift: 0px; } to { --fab-lift: 6px; } }
@keyframes fab-clear { from { --fab-lift: 0px; } to { --fab-lift: 56px; } }

@media (prefers-reduced-motion: reduce) {
  .fab, .fab:hover { animation: none; }
  .has-snackbar .fab, .has-snackbar .fab:hover { animation: none; --fab-lift: 56px; }
}

Rendering Impact: main-thread style per frame, then composite. Animating a custom property requires style recalculation on each frame, because the browser has to recompute the translate that consumes it; the resulting transform is composited. For a single button this is negligible, but hundreds of elements animating registered properties add measurable style work.

This trade-off is the price of the model. Animating translate directly lets the compositor sample the animation without the main thread; animating a variable that feeds translate does not. Use variable composition for a few elements where clarity matters, and direct property animation for large numbers of elements.

Main-thread style time per frame, 200 animated elementsMid-range phone. The variable approach costs style work that direct transform animation avoids.Main-thread style time per frame, 200 animated elementstranslate animated directly0.3 msRegistered --lift feeding translate3.8 msUnregistered --lift (discrete)0.4 ms
Mid-range phone. The variable approach costs style work that direct transform animation avoids.

Verification checklist

Constraints and trade-offs

  • Custom-property animations need a style recalculation every frame; they are not compositor-only.
  • inherits: false keeps the animated value from recalculating descendants’ styles.
  • Without @property support, the variables change discretely and nothing sums.
  • Paused effects in the animation list still contribute their current value; pause at the start keyframe if they should contribute zero.
  • A single consumer makes the total easy to clamp with min() or max() if effects could overshoot.

Clamping and inspecting the total

Summed contributions can overshoot. If the snackbar appears while the hover lift is active and a future “notification badge” effect adds another few pixels, the button can end up further from its anchor than the design allows. Because there is a single consumer, the fix is one expression: translate: 0 calc(-1 * min(var(--fab-lift), 64px)) caps the visible offset while the contributions keep summing underneath.

Inspecting the total is equally direct. In the Elements panel’s Computed tab, registered custom properties show their current animated value, so you can hover the button while a snackbar is visible and watch --fab-lift settle at the expected sum. getComputedStyle(el).getPropertyValue('--fab-lift') returns the same value from script, which makes the arithmetic easy to assert in a test.

The same pattern generalises beyond offsets. A registered <number> for “attention intensity” can be fed into scale, opacity and a shadow’s alpha at once, with separate animations for pulse, hover and error each contributing a share. Keep contributions small, document the maximum, and clamp at the consumer.

Frequently asked questions

Why does my custom property animation jump halfway?

The property is not registered, so the browser treats its value as a string and animates it discretely. Register it with @property and a numeric syntax.

Is there a difference between add and accumulate for custom properties?

Not for single numbers, lengths, angles or percentages. Both sum the values. The difference only matters for list-valued properties such as transform and filter.

Are custom property animations slower than transform animations?

They require main-thread style recalculation on every frame, whereas a transform animation can be sampled on the compositor. For a handful of elements the difference is negligible; for many it is measurable.