Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 8.4Intermediate ~23 min · shot 4 of 6

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().

0/6 · 0%

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. 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. 2

    Make the animation not fill

    In "The fill trap", delete fill: "forwards" from the first animate() call. After finish() the opacity is back to 1 and class .low now works. Without a fill the animation has no effect once it ends. The first line says finished, opacity 1.

Code & diagrams

The fill trap

The class .low sets opacity 0.3, but a finished fill: forwards animation is still on top. The buttons repeat the trap by hand.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

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 0
Commit, then cancel

Chromium 153. The first lines are the pattern, the next ones are its edge cases.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

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.9
Hundreds of filling animations

Each animation lasts 1 ms and fills forwards. Count them with getAnimations() and read replaceState.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

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: true

Break 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().

terminal
$ Read the console
── what you'll see ──
hidden element -> InvalidStateError: Failed to execute 'commitStyles' on 'Animation': Target element is not rendered.
Captured from a real run.

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 active after a newer transform-only one, and became removed once an opacity one arrived.

Remember this

  1. 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 for fill: "forwards" and meet three more problems.

  2. 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 sets opacity: 0.3 and nothing changes. The example prints class .low says opacity 0.3, computed: 0.6. A :hover rule loses the same way, and the bug looks like a CSS bug.

  3. 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 (replaceState becomes "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. 4

    **persist() and removed animations.** Calling persist() on an animation before a newer one replaces it opts that animation out of the clean-up (replaceState becomes "persisted"; the example shows two animations left instead of one). A removed animation still works for commitStyles() (MDN).

  5. 5

    The better pattern: commit, then cancel. commitStyles() writes the animation's current values into the element's style attribute as ordinary inline styles. Then cancel() removes the animation. The element keeps the look, there is nothing left in getAnimations(), and the value is a normal style again. The example shows opacity: 0.5; transform: translateX(80px); in the style attribute and 0 animations left. Older browsers needed fill: "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. 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 throws InvalidStateError ("Target element is not rendered") when the element is display: none or 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 with opacity: 0.9 loses until the style attribute is cleared.

  7. 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

01

Why can a class not override a finished animation with fill: "forwards"?

02

What is the commit-then-cancel pattern, and what does it leave behind?

03

When does the browser remove a filling animation by itself?

Practice

01

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.

02

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