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.
Decision matrix
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. RewindingcurrentTimeis 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.
Verification checklist
Constraints and trade-offs
getAnimations()returns effects in an unspecified order, so filter byanimationNamerather 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 ontransitionProperty. - 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.
Related
- Animation State Management — the parent guide on owning animation state without parallel flags
- Controlling Playback Rate and Reverse with WAAPI — the smooth alternative to a hard rewind
- Avoiding Layout Thrashing in CSS Animations — why the reflow hack is worth removing