Shots in this scene
Keeping the End State
In one line
When a Web Animations API animation ends, the element jumps back to its own style. fill: "forwards" stops that, but it holds the value above your CSS and piles up. The better pattern is commitStyles() then cancel().
Think of it like this
An actor finishes a scene, and the stage lights go back to how they were. If you want the actor to stay in the pose, you can either freeze the scene (the lights stay on the pose, and nobody else can change the lights) or move the actor to the pose in ordinary light and stop the scene. The first is fill: "forwards". The second is commitStyles() and cancel().
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- fill
- What an animation shows before it starts and after it ends: none (nothing), forwards (the end values), backwards (the start values during a delay), both.
- filling animation
- A finished animation whose fill keeps its end values applied to the element.
- commitStyles()
- Writes the animation's current values into the element's style attribute as inline styles.
- replaceState
- active, removed or persisted. An animation becomes removed when a newer animation overrides all its properties.
- persist()
- Keeps an animation from being removed automatically when a newer one overrides it.
- cascade
- The rules that decide which style wins. Animations come above normal rules; inline styles come below animations.
Step by step
01See the fill trap in the console
Run "The fill trap". Three lines: after finish() the animation is finished and opacity is 0.6; adding the class .low (opacity 0.3) changes nothing (still 0.6); after cancel(), opacity is 1 and there are no animations. The finished animation was still in charge the whole time.
02Do it by hand with the buttons
Press Fade (forwards), then Toggle .low. The bar stays at 0.6 and its track on the timeline reads finished, not gone. Press Cancel it: the bar jumps to 0.3 at once, because the class was waiting underneath.
03Commit, then cancel
"Commit, then cancel" prints the style attribute after the pattern: opacity: 0.5; transform: translateX(80px);. The element holds the look as inline style, and animations left: 0.
04Count the pile-up
"Hundreds of filling animations" creates 300 and prints 300; three frames later it prints 1. The other 299 were removed automatically, because each one was replaced by a newer one on the same property.
Try it yourself
- 1
Cancel and watch the class take over
In "The fill trap", press Fade (forwards), then Toggle .low, then Cancel it. In a real run the opacity stayed at 0.6 after the toggle and dropped to 0.3 the moment Cancel was pressed: the class had been applied all along, hidden under the animation.
- 2
Make the animation not fill
In "The fill trap", delete
fill: "forwards"from the firstanimate()call. Afterfinish()the opacity is back to 1 andclass .lownow works. Without a fill the animation has no effect once it ends. The first line saysfinished, opacity 1.
Code & diagrams
The class .low sets opacity 0.3, but a finished fill: forwards animation is still on top. The buttons repeat the trap by hand.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
after finish(): finished, opacity 0.6
class .low says opacity 0.3, computed: 0.6
after cancel(): opacity 1, animations 0Chromium 153. The first lines are the pattern, the next ones are its edge cases.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
committed, then cancelled -> inline style: opacity: 0.5; transform: translateX(80px);
animations left: 0, transform: matrix(1, 0, 0, 1, 80, 0)
hidden element -> InvalidStateError: Failed to execute 'commitStyles' on 'Animation': Target element is not rendered.
committed at 500 ms of 1000 -> opacity: 0.6
a class with opacity 0.9 now loses to the inline style: 0.6
after removing the inline style: 0.9Each animation lasts 1 ms and fills forwards. Count them with getAnimations() and read replaceState.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
300 forwards animations created -> getAnimations(): 300
3 frames later -> getAnimations(): 1, the first one is removed
A (opacity + transform), then B (transform only) -> A is active
then C (opacity only) -> A is removed
persist() on one, then a newer one -> getAnimations(): 2, the persisted one is persisted
commitStyles() on a removed animation does not throw: trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Commit on a hidden element
In "Commit, then cancel", nothing to change: it is the third line of output. In your own code, set display: none on the element before the animation ends and then call commitStyles().
Myth vs fact
Myth
fill: "forwards" is how you keep the end of an animation.
Fact
It is a way, and a poor one: the animation stays attached and wins over your CSS. Committing the end values as a style and cancelling the animation leaves a normal, overridable style and no animation.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Automatic removal only happens when *every* property of the old animation is overridden by newer animations. In a real run an animation of opacity and transform stayed
activeafter a newer transform-only one, and becameremovedonce an opacity one arrived.
Remember this
- 1
The problem this solves:
el.animate({ opacity: [1, 0] }, 300)fades the element out, and then, at the end, it pops back to full opacity. By default an animation has no effect before it starts or after it ends (fill: "none"). Most people reach forfill: "forwards"and meet three more problems. - 2
The fill trap, part 1: it beats your styles. An animation's value sits above stylesheet rules and inline styles in the cascade. A finished animation with
fill: "forwards"is still *filling*, so it still wins: add a class that setsopacity: 0.3and nothing changes. The example printsclass .low says opacity 0.3, computed: 0.6. A:hoverrule loses the same way, and the bug looks like a CSS bug. - 3
The fill trap, part 2: they pile up. Every
animate()call with a fill adds one more animation to the element, and each one stays alive. Run a hover effect 300 times and 300 finished animations are attached, all holding a value. Browsers do clean up: a finished, filling animation is automatically removed when every property it animates is overridden by a newer animation (replaceStatebecomes"removed"). The example shows 300 become 1 after a few frames. An animation of opacity *and* transform is not removed by a newer one that sets only transform. - 4
**
persist()and removed animations.** Callingpersist()on an animation before a newer one replaces it opts that animation out of the clean-up (replaceStatebecomes"persisted"; the example shows two animations left instead of one). A removed animation still works forcommitStyles()(MDN). - 5
The better pattern: commit, then cancel.
commitStyles()writes the animation's current values into the element'sstyleattribute as ordinary inline styles. Thencancel()removes the animation. The element keeps the look, there is nothing left ingetAnimations(), and the value is a normal style again. The example showsopacity: 0.5; transform: translateX(80px);in the style attribute and 0 animations left. Older browsers neededfill: "forwards"first, so that there was an end state to commit. In headless Chromium 153 a finished animation with no fill committed its end value too, but you cannot feature-test for it, so most code still sets the fill. - 6
What commit does not do. It works on a mid-way animation too: committing at 500 ms of a 1000 ms fade wrote
opacity: 0.6. It throwsInvalidStateError("Target element is not rendered") when the element isdisplay: noneor detached, because there is nothing to compute. And the committed values are now *inline* styles: they beat your stylesheet rules until you remove them. In the example a class withopacity: 0.9loses until the style attribute is cleared. - 7
Choosing. No fill for motion that returns to rest (a bump, a flash, the fly-to-cart dot). Commit then cancel when the end becomes the new state (a card that slides into place).
fill: "forwards"only when you will cancel it soon. Often best: change a class and let a CSS transition do it (Phase 3).
Explain it without notes
Why can a class not override a finished animation with fill: "forwards"?
What is the commit-then-cancel pattern, and what does it leave behind?
When does the browser remove a filling animation by itself?
Practice
Write slideIn(el) for a card that slides 100 px to the right and stays there, with no animation left attached when it is done.
A bump animation (scale 1 to 1.4 and back) runs on every click and must not leave animations behind. Which fill do you use, and how do you check?
Done when you can
You can explain why fill: "forwards" can break a class or hover style
You can use commitStyles() then cancel() to keep an end state
You can say what automatic removal is and when persist() matters
You can name the commitStyles() failure for a hidden element