commitStyles() and Persisting Animation End State
Part of Web Animations API Integration in Core CSS Animation Fundamentals.
The problem
An element animates to a new position with fill: 'forwards', the promise resolves, everything looks correct — and then, a frame or two later, the element jumps back to where it started. The animation did not fail; it was removed. fill: 'forwards' describes what the effect does while it exists, and nothing in that phrase promises the effect will exist forever.
This is the single most common surprise when moving motion from CSS to the Web Animations API, because the declarative form has no equivalent: a CSS animation with animation-fill-mode: forwards keeps filling as long as the rule matches, and rules do not spontaneously stop matching.
Root cause: replaceable animations are removed on purpose
When a scripted animation finishes and is filling forwards, the browser marks it replaceable. At the next opportunity it removes any replaceable animation whose effect is entirely superseded — because otherwise a page that animates one property fifty times accumulates fifty finished effects, each still contributing to the computed value, each still holding a reference to its target.
Removal is the correct default. The bug is not the removal; it is that the element’s cascaded style still says where it used to be. The animation was the only thing asserting the new position, and when it goes, the cascade wins again.
That framing points at three solutions, in order of preference: make the cascade agree, write the value onto the element, or stop the removal.
Decision matrix
Production pattern
The robust shape is: await the promise, commit, cancel — and prefer a class when you can.
// Intent: fling a card to a runtime-computed position and keep it there,
// without leaving a finished effect on the stack.
async function settleAt(card, x) {
const animation = card.animate(
[{ translate: '0 0' }, { translate: `${x}px 0` }],
{ duration: 240, easing: 'cubic-bezier(0.2, 0, 0, 1)', fill: 'forwards' }
);
try {
await animation.finished;
// Write the computed end values onto the element as inline style...
animation.commitStyles();
// ...then remove the effect, now that something else asserts the value.
animation.cancel();
} catch (error) {
if (error.name !== 'AbortError') throw error; // cancelled elsewhere
}
}
/* Intent: where the end state IS known at author time, no commit is needed —
the cascade already agrees with the animation's final frame. */
.card--dismissed {
translate: 100% 0;
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
/* No fling at all: the state class alone puts the card where it belongs. */
.card { transition: opacity 120ms linear; }
}
Rendering Impact:
translate— composite while animating.commitStyles()costs one style invalidation on that element after the animation has already finished, so it never lands inside an animating frame.
The ordering matters. Committing before the animation finishes writes an intermediate value; cancelling before committing loses the value entirely. And if the component is torn down mid-flight, the AbortError path skips both, which is correct — there is nothing left to persist.
Verification checklist
Constraints and trade-offs
commitStyles()throws if the element is not rendered, so guard it when the component may have been removed during the animation.- Committed values land as inline styles, which outrank stylesheet rules — a later class change may appear to have no effect until the inline value is cleared.
- Frameworks that reconcile inline styles can overwrite a committed value on the next render; in that case the state belongs in the framework’s own state, not in a commit.
persist()keeps every finished effect alive, and effects on the same property then resolve by composite order rather than by recency.- Committing a shorthand commits every longhand it expands to, which can freeze properties you did not intend to pin.
Frequently asked questions
Why does fill: forwards not keep the end state?
It does keep it, right up until the browser removes the animation. A finished, filling animation is ‘replaceable’, and the engine removes replaceable animations automatically so the effect stack does not grow without bound. Once removed, the element renders its cascaded style again — which is the snap you see.
Is commitStyles() expensive?
It writes computed values to the element’s inline style, so it costs a style invalidation for that element and nothing more. The cost is negligible next to the snap it prevents. What is expensive is calling it on every frame rather than once, after finished settles.
Should I use persist() instead?
Rarely. persist() stops the automatic removal, which fixes the snap but leaves the animation in the effect stack forever. Ten interactions produce ten persisted effects on the same property, and the composite order between them becomes the thing you are debugging.
What the effect stack looks like after a leak
The symptom of a missing cleanup is not a crash; it is an element that becomes progressively harder to style. Each finished-but-persisted effect keeps contributing its end value to the property it animated, and scripted effects outrank the cascade — so after a dozen interactions, a stylesheet rule that sets translate on the element does nothing at all, and nobody can see why.
Diagnosing it takes one console expression. $0.getAnimations().length on the affected element should return to the same small number after every interaction. If it climbs, read the entries: $0.getAnimations().map(a => [a.playState, a.effect.getKeyframes().map(k => Object.keys(k))]) shows which properties each surviving effect is still asserting.
The fix is always in the same place — the code path that ends the interaction. Committing without cancelling leaves the effect; cancelling without committing loses the value; doing neither leaves both. The try/finally shape in the pattern above exists precisely so that an early return or a thrown error cannot skip the cleanup, because the leak is invisible until it is large.
There is one legitimate exception. An effect deliberately kept with persist() for a value that no stylesheet can express — a runtime-measured position that must survive re-renders — is a design decision rather than a leak. Record it in a comment, because the next person reading getAnimations().length will otherwise treat it as one.
Related
- Web Animations API Integration — the parent guide, including the rest of the lifecycle obligations
- element.animate() vs CSS Keyframes — choosing the mechanism before you have to persist anything
- Animation State Management — keeping the DOM state and the animation state in agreement