Cancel, finish and retain animated state safely

How-to Last tested 24 October 2026

Give each animation one owner, decide what cancellation should leave behind, and handle its completion promise as a result that can become obsolete. For an interrupted move, retain the displayed position before removing the effect; for a reset, restore an explicit baseline instead.

Decide what stopping means

A parcel can stop because the user wants to keep its current position, wants a different destination, wants the existing move completed, or wants the original state restored. Those are four different intentions. A single function called “stop” conceals the decision that determines the next rendered state.

The original component has two destinations: a horizontal translation of zero or 240 CSS pixels. A move lasts a nominal 1,600 milliseconds with linear easing unless the reduced-motion preference requests the zero-duration branch. These numbers define this example, not a recommended duration or an observed display rate. The parcel’s label and the controls stay outside any timed task.

ActionState to retainEffect ownership
Hold positionCurrent computed transformCancel the active effect
Move to another destinationCurrent transform as the next startReplace the previous effect
Finish nowExisting destinationComplete, then remove the effect
ResetExplicit zero translationCancel and discard the current move

Use the table before selecting an API call. If an application instead wants cancellation to restore the last confirmed destination, change that policy explicitly. Retaining the current interpolation is a design choice, not the universal meaning of cancellation.

Read the current position before releasing it

The release() function reads the parcel’s computed transform while the old effect still applies. It then increments a generation number, cancels the effect, clears the active reference, and writes either the captured transform or the reset transform to the inline style. The order matters to this design: reading after removal would no longer describe the interrupted presentation.

The example owns only transform on this parcel. It does not merge independent rotation, scale or translation owners. If another subsystem writes that property, the captured matrix can include its contribution and the next inline write can take over more than intended. Establish property ownership before treating a computed value as application state.

This code has no asynchronous gap between its read and write. That is an implementation property, not proof of visual continuity under every condition. Layout changes, removed elements, another animation source and rapid event sequences need their own cases. The recorded run below is deliberately bounded to the original document.

Separate cancellation from failure

For a non-idle animation, cancellation rejects its current finished promise with an AbortError; the cancel() method itself does not directly throw that error 1. Attach rejection handling when the animation is created. Do not leave cancellation as an unhandled side effect of a later reset button.

The handler here treats AbortError as an expected interruption and labels other rejections as an animation error. It does not silently convert an arbitrary failure into successful completion. A real application may need an error-reporting path with more detail, but that path should remain separate from the ordinary user action of stopping motion.

The generation number addresses a different problem: a previous completion handler might run after another request has become authoritative. Every release changes the generation. The completion handler checks both that number and the active animation reference before writing a final destination. An obsolete handler therefore has no authority to update the current parcel.

Retain the final state without keeping the effect

The finished property resolves when its animation finishes, and a new promise is created when the animation leaves the finished state to play again 2. This implementation creates a fresh animation for each destination request and attaches its handler immediately. It does not store one promise as a permanent completion signal for the parcel.

At accepted completion, the code writes the known destination into style.transform, removes the active reference, and cancels the now-unneeded effect. The inline state is then the declared owner of the endpoint. The code does not leave a stack of filling effects as its storage mechanism.

commitStyles() is another option: it writes the animation’s current computed values into the target’s inline style 3. This example uses an explicit transform write because it already knows the final destination and owns one property. Choose between those approaches according to which values must be retained, and test the handoff rather than assuming either approach preserves every property correctly.

The component

Original code, MIT licensed. Save this page locally. The separate demo adds the exact review sequence without changing the component’s behavior.

<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Move and retain a package position</title>
    <style>
      body { font: 1rem/1.5 system-ui; margin: 1rem; }
      .track { width: 320px; max-width: 100%; padding: 1rem 0; border-bottom: 2px solid; }
      #package { width: 64px; padding: .5rem 0; text-align: center; background: #174f76; color: white; }
      button { margin: .25rem; padding: .5rem; }
      :focus-visible { outline: 3px solid #b24d00; outline-offset: 3px; }
    </style>
  </head>
  <body>
    <p>Choose a destination, keep the current position, or reset it.</p>
    <button id="left">Move left</button>
    <button id="right">Move right</button>
    <button id="hold">Hold position</button>
    <button id="finish">Finish now</button>
    <button id="reset">Reset</button>
    <div class="track">
      <div id="package">Parcel</div>
    </div>
    <p id="state" role="status">At start</p>
    <script>
      const parcel = document.querySelector("#package");
      const state = document.querySelector("#state");
      let current = null;
      let generation = 0;
      function release(keep) {
        const position = getComputedStyle(parcel).transform;
        generation += 1;
        current?.cancel();
        current = null;
        parcel.style.transform = keep ? position : "translateX(0px)";
      }
      function move(destination) {
        release(true);
        const ticket = generation;
        const start = getComputedStyle(parcel).transform;
        const end = `translateX(${destination}px)`;
        const duration = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 1600;
        const animation = parcel.animate(
          [{ transform: start }, { transform: end }],
          { duration, fill: "forwards", easing: "linear" }
        );
        current = animation;
        state.textContent = "Moving";
        animation.finished.then(() => {
          if (generation !== ticket || current !== animation) return;
          parcel.style.transform = end;
          current = null;
          animation.cancel();
          state.textContent = destination ? "At right" : "At left";
        }).catch(error => {
          if (error.name !== "AbortError") state.textContent = "Animation error";
        });
      }
      document.querySelector("#left").onclick = () => move(0);
      document.querySelector("#right").onclick = () => move(240);
      document.querySelector("#hold").onclick = () => {
        release(true);
        state.textContent = "Held";
      };
      document.querySelector("#finish").onclick = () => current?.finish();
      document.querySelector("#reset").onclick = () => {
        release(false);
        state.textContent = "At start";
      };
    </script>
  </body>
</html>

Exercise interruption before adding more effects

Start a rightward move and hold it before completion. Compare the displayed position immediately after Hold and again later. Then request the right destination and quickly request the left one. The relevant question is whether the newer destination owns the final state, not whether both callbacks eventually ran.

Finish now is a separate path. It asks the active animation to finish; the same completion handler owns the final write. Pressing it with no active effect has no requested action. Reset takes the other path: invalidate the request, remove the effect and write the explicit baseline. Keep those paths separate when adding application events.

This is retargeting from a captured position, not velocity-preserving reversal. Each new move gets the full declared duration, even when little distance remains. That choice changes the motion’s slope. If velocity continuity matters, specify it numerically and build a different timing model; do not describe this implementation as preserving it.

Not covered on this page

Sources

  1. MDN, Animation: cancel() method, living documentation. https://developer.mozilla.org/en-US/docs/Web/API/Animation/cancel (opened 24 October 2026). ↩

  2. MDN, Animation: finished property, living documentation. https://developer.mozilla.org/en-US/docs/Web/API/Animation/finished (opened 24 October 2026). ↩

  3. MDN, Animation: commitStyles() method, living documentation. https://developer.mozilla.org/en-US/docs/Web/API/Animation/commitStyles (opened 24 October 2026). ↩

Code on this page is original and MIT licensed.

How we test: methods. Found a mistake? Corrections.