skip to content

Removing a CSS animation class from an element and immediately re-adding it in the same script task does not replay the animation. Why, and what actually restarts it?

level: seniorimportance: should knowfreq 38%

answer

  1. animations bind to computed animation-name
  2. styles are recalculated in batches
  3. no observed change, no new animation
  4. paused is not rewound
  5. two names, or the animation object

basics

~20 s

A keyframe animation is created when an element's computed animation-name gains a value and destroyed when it loses one. Remove and re-add in one task and the computed value is identical at the next style recalculation, so the browser sees no change and no new animation starts.

solid answer

~50 s

CSS animations are bound to the *computed value* of `animation-name`, and that value is only re-evaluated when styles are recalculated — not on every DOM mutation. If you remove the class and re-add it before the browser gets a chance to recompute, the before and after values are the same, so no animation is destroyed and none is created; a finished animation simply stays finished. The reliable fixes all make the computed value genuinely change: set `animation: none`, force a style recalculation, then remove the override; swap between two identically-defined `@keyframes` names; or clone and replace the element. The cleanest modern option is to skip the class dance and use the animation objects directly — `element.getAnimations()` returns them, and setting `currentTime` to 0 or calling `cancel()` then `play()` restarts from the first keyframe without touching the stylesheet.

code

javascript · 20 lines
javascript
// Fails: both writes land before the next style recalculation.
function naiveReplay(el) {
  el.classList.remove('shake');
  el.classList.add('shake');
}

// Works: forces the browser to observe animation-name: none in between.
function replayByReflow(el) {
  el.classList.remove('shake');
  void el.offsetWidth;
  el.classList.add('shake');
}

// Clearest: reset the animations already attached to the element.
function replayByAnimationObject(el) {
  for (const anim of el.getAnimations()) {
    anim.currentTime = 0;
    anim.play();
  }
}

go deeper

for a junior

Know that re-adding the same class does not replay a CSS animation, and that there is a standard trick involving forcing the browser to notice the change in between.

for a middle

Explain that animations are created and destroyed from changes in the computed animation-name, that styles are recalculated in batches, and why that makes the naive toggle a no-op.

for a senior

Compare the real options — forced recalculation, alternating keyframe names, resetting the animation object's currentTime — on readability and cost, and use the animation events to verify a restart actually happened.

for a principal

Own the pattern the codebase uses for replayable motion, so the fragile forced-read idiom does not spread uncommented through components where a later refactor silently removes it.

## When an animation is created A keyframe animation is not attached to a class or to a DOM mutation. It is attached to the element's **computed `animation-name`**. When style is recalculated and an element's computed `animation-name` gains a name it did not have, an animation is created and starts. When it loses one, that animation is cancelled and disappears. Everything about restarting follows from that. Computed values are not recomputed on every DOM write. The browser batches style recalculation and does it when it needs the result — before rendering the next frame, or when script asks a question that requires up-to-date styles. So a sequence like this: ```js el.classList.remove('shake'); el.classList.add('shake'); ``` never produces two different computed values. Both mutations land before the next recalculation; the value read is `shake` both before and after; nothing was removed and nothing was created. If the animation already finished, it stays finished, and the element sits at its cascaded values (or its fill). ## Fix 1: make the value actually change Force a recalculation between the two writes, so the browser observes `none` and then `shake`: ```js el.classList.remove('shake'); void el.offsetWidth; // read a value that requires up-to-date layout el.classList.add('shake'); ``` The read in the middle is not superstition — asking for a geometry value obliges the browser to bring styles up to date first, which is exactly the observation point the animation machinery needs. It works, but it is opaque, easy to "optimise away" in review, and costs a synchronous style/layout pass. ## Fix 2: swap the name Keep two rules whose `@keyframes` are identical but differently named, and alternate between them. The computed `animation-name` genuinely differs, so the old animation is cancelled and a new one is created with no forced recalculation and no timing tricks. The cost is duplicated keyframes, which is why this is usually done with a small pair of utility classes rather than per component. ## Fix 3: drive the animation object Modern browsers expose the running CSS animations as objects: `element.getAnimations()` returns them, each with `currentTime`, `play()`, `pause()`, `cancel()` and a `finished` promise. Restarting is then explicit: ```js for (const anim of el.getAnimations()) { anim.currentTime = 0; anim.play(); } ``` This is the most direct expression of the intent, needs no forced recalculation, and works whether or not the animation had already finished. Note it acts on the animations currently attached to the element, so it restarts what is there rather than causing a class to reapply. ## What does *not* restart an animation `animation-play-state` is a pause switch, not a rewind. Setting it to `paused` and back to `running` resumes from the elapsed position — the animation's clock was frozen, not reset. Similarly, re-declaring the same `animation` shorthand with the same values, changing `animation-delay`, or bumping `animation-iteration-count` on an animation that already completed does not rewind it, because none of those recreate the animation. One thing that *does* cancel it: the element becoming un-rendered, for example via `display: none`. Restoring it creates a fresh animation, which is why a modal that toggles `display` appears to replay its entrance every time while one that toggles a class does not. ## The events you can observe When an animation is cancelled before completing, the element gets an `animationcancel` event; a completed one gets `animationend`. Watching these while debugging tells you immediately whether the browser is destroying and recreating animations or just leaving one in place — which resolves the "is my restart working" question far faster than staring at the visual result. ## Choosing For one-off attention motion in application code, `getAnimations()` with `currentTime = 0` is the clearest. For a design-system utility that must not depend on script structure, the two-name swap is the most robust. Reserve the forced-recalculation trick for code that must stay in the class-toggling idiom, and comment it — an uncommented `void el.offsetWidth` is deleted by the next reader within a month.

  • Why does setting animation-play-state to paused and back not restart the animation?
    Because `animation-play-state` only stops and resumes the animation's clock. The animation object itself is never destroyed or recreated, so its elapsed time is preserved and it continues from where it froze. Restarting requires either creating a new animation — a genuine change to the computed `animation-name` — or resetting the existing one's `currentTime` to zero.
  • Why does toggling display: none and back appear to replay the animation?
    Making an element un-rendered cancels its animations; rendering it again causes a fresh animation to be created from the computed `animation-name`, which starts from the first keyframe. It genuinely is a new animation, and you can observe the `animationcancel` and then `animationstart` events. It works, but hiding an element purely to replay motion has obvious layout and accessibility side effects.
  • How would you know, while debugging, whether a restart attempt actually worked?
    Listen for the animation events. A real restart cancels the old animation and creates a new one, so you see `animationcancel` (or `animationend` if it had completed) followed by `animationstart`. If your toggle produces no events at all, the computed `animation-name` never changed and the browser did nothing — which is the failure mode, visible without watching pixels.

saying these in an interview costs you the question

  • Thinking a DOM mutation alone recreates the animation
  • Believing play-state paused then running rewinds it
  • Calling the forced style read a rendering hack
  • Assuming re-declaring the same animation restarts it
  • Expecting a longer delay to replay a finished animation

context