Cancelling and Restarting Animations Without Flicker

Part of Animation State Management in Core CSS Animation Fundamentals.

The problem

A validation error shakes an input to draw attention to it. The user submits again with the same mistake, the code re-adds the is-invalid class — and nothing happens. The shake played once and will not play again until something unrelated forces a style change, at which point it fires at a moment nobody expects.

The usual fix found in a search result is to read element.offsetWidth between removing and re-adding the class. It works, and it quietly adds a forced synchronous layout to a code path that runs on every failed submission.

Root cause: style is resolved per frame, not per write

The browser does not recompute style after every DOM mutation; it batches. Within one task you can add, remove and re-add a class as many times as you like, and the only thing that matters is the computed style when the task ends. Two writes that cancel out are, from the style engine’s perspective, no change at all — so the animation is never removed and never re-created, and @keyframes rules do not replay on their own.

Reading a layout property in between forces the engine to resolve style and layout now, which is why the trick works: the browser genuinely sees the class as absent, drops the animation, then sees it return and creates a new one. The price is a synchronous layout in the middle of an interaction handler, the pattern described in avoiding layout thrashing in CSS animations.

The Web Animations API sidesteps the whole issue because it addresses the animation rather than the style that produced it. getAnimations() returns the live CSSAnimation object, and setting its currentTime back to zero restarts it — no class churn, no layout, no flicker.

Why the class toggle is a no-opThe two writes never produce a distinct computed style, so no new animation is ever created.Why the class toggle is a no-opclassList.remove()queuedclassList.add()same taskStyleresolvedonceend of taskNo changedetectedanimationsurvivesNothingreplays
The two writes never produce a distinct computed style, so no new animation is ever created.

Decision matrix

Four ways to make an animation play againThe first is the default, the last is the one to reach for.Four ways to make an animation play againCosts a layout?Restarts reliably?VerdictRemove and re-addthe classNoNoSilently does nothingToggle across twoframes with rAFNoYesWorks, one frame lateRead offsetWidthbetween writesYesYesWorks, forces layoutanimation.currentTime= 0NoYesBestcancel() then play()NoYesBest for scriptedeffects
The first is the default, the last is the one to reach for.

The requestAnimationFrame variant is worth knowing for cases where you do not have a handle on the animation — remove the class, wait one frame, add it back. It is correct and cheap, but the replay starts a frame later than the interaction, which is perceptible on a feedback animation that is supposed to feel immediate.

Production code pattern

// Intent: replay a validation shake instantly, however many times the user
// submits, with no forced layout and no dropped first frame.
function shake(field) {
  // The class stays on permanently; we only control the animation's clock.
  field.classList.add('is-invalid');

  const [shakeAnimation] = field.getAnimations()
    .filter((a) => a.animationName === 'field-shake');

  if (!shakeAnimation) return;          // reduced motion removed it entirely

  if (shakeAnimation.playState === 'finished' ||
      shakeAnimation.playState === 'running') {
    shakeAnimation.currentTime = 0;     // rewind in place: no DOM write at all
    shakeAnimation.play();
  }
}
/* Intent: the animation is declared once and stays attached; JavaScript owns
   only its clock. Under reduced motion it is removed, and the field is marked
   by colour and an icon instead of by movement. */
@keyframes field-shake {
  0%, 100% { translate: 0 0; }
  20%      { translate: -6px 0; }
  40%      { translate: 5px 0; }
  60%      { translate: -3px 0; }
  80%      { translate: 2px 0; }
}

.is-invalid {
  animation: field-shake 320ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
  border-color: #b91c1c;
}

@media (prefers-reduced-motion: reduce) {
  .is-invalid { animation: none; }   /* colour and the error text carry it */
}

Rendering Impact: translate — composite only. Rewinding currentTime is a timeline write on an existing effect; it triggers no style invalidation, no layout and no repaint.

Two details make this robust. The class is added once and never removed, so the error styling is not flickering along with the animation. And the getAnimations() filter means the code degrades correctly under reduced motion: when the media query removes the animation, the array is empty, the function returns early, and the field is still marked invalid by colour and text.

Replacing a reflow hack in an existing codebaseEach step removes one forced layout from an interaction path.Replacing a reflow hack in an existing codebase1Search for offsetWidth, offsetHeight or getBoundingClientRect reads thatexist only to force a reflow.They are usually adjacent to a classList call2Leave the class in place and fetch the animation with getAnimations().No DOM mutation on replay3Rewind with currentTime = 0 and call play().The replay starts on the same frame as the interaction4Guard for an empty array so the reduced-motion path returns early.No error when the animation was gated away
Each step removes one forced layout from an interaction path.

Verification checklist

Constraints and trade-offs

  • getAnimations() returns effects in an unspecified order, so filter by animationName rather than indexing blindly.
  • Rewinding a running effect is visually abrupt by design; if a smooth interruption is wanted, reverse instead, as covered in controlling playback rate and reverse.
  • Effects created by a transition rather than an animation have no animationName; match them on transitionProperty.
  • Rewinding does not re-run any JavaScript that ran on the first play, so side effects belonging to the animation must be triggered explicitly.
  • A component that is re-rendered between triggers gets a new element and therefore a new animation; the handle must be re-fetched, never cached across renders.

Frequently asked questions

Why does removing and re-adding a class not restart the animation?

Because style is resolved once per frame, not per DOM write. Both writes happen in the same task, so the computed style at the end of the task is identical to what it was at the start — the browser never sees the class as absent and has no reason to create a new animation.

Is the offsetWidth reflow trick safe?

It works, and it costs a forced synchronous layout every time it runs. On a single button press that is acceptable; inside a loop or a scroll handler it is exactly the layout thrashing pattern to avoid. The animation object gives the same result with no layout.

How do I restart a CSS animation from JavaScript without touching classes?

Call getAnimations() on the element, find the CSSAnimation you want, and set currentTime to zero — or call cancel() followed by play(). Both restart the effect without any DOM mutation and without forcing layout.