← blog
JavaScriptAugust 25, 2026 · 5 min read

CSS Animations Can't Pause. The Web Animations API Can.

Toggling a class to restart a CSS animation works — until you need to pause it, reverse it mid-flight, or ask how far through it is. element.animate() returns an object with those methods built in, natively, in every browser you already support.

Parsa Jiravand · Frontend engineer · building bestpractic
CSS Animations Can't Pause. The Web Animations API Can.

You built a shake animation for invalid form fields. Nice touch — the field jitters, the user notices, everyone's happy. Then someone on your team clicks "submit" three times fast, and the shake either doesn't restart, or restarts in a visibly broken half-state. You go find the fix online. It's this:

JavaScript
1
2
3
4
5
function shake(field) { field.classList.remove('shake'); void field.offsetWidth; // force a reflow so the browser "forgets" the animation ran field.classList.add('shake'); }

That void field.offsetWidth line is a real, documented, widely-used trick. It works. It's also the moment you should notice something: you're forcing a synchronous layout recalculation just to convince the browser your animation is allowed to run again. That's not a small workaround. That's the entire toolkit CSS gives you for controlling an animation from JavaScript — restart it, or don't touch it.

The reflow hack gets you "restart from the top." Try to go one step further and CSS stops helping.

Say product wants the shake to pause if the user is still typing, resume if they stop, and — this is the one that breaks people — reverse smoothly if they fix the field before the shake finishes. With a CSS class, here's what you actually have access to: animation-play-state: paused, and that's it. No reverse. No "jump to 40% through." No way to ask "how many milliseconds into this animation are we right now" without reaching for getComputedStyle() and parsing a matrix transform out of a string.

CSS
1
2
3
4
5
6
.field.shake { animation: shake 400ms ease; } .field.shake.held { animation-play-state: paused; }

Pausing works. Now try to resume from wherever it paused, then reverse from there — not from the start, from mid-shake — and you're out of CSS. The animation is a black box with a play/pause switch soldered to the outside. You never get a handle to the thing itself.

Before you scroll: think about how you'd even represent "which part of the animation are we in" if you had to write it by hand. A timer? A percentage you track separately and hope stays in sync? That's the shape of the problem.

Here's the same shake, written with the DOM method that's been sitting in every major browser for years:

JavaScript
1
2
3
4
5
6
7
8
9
const anim = field.animate( [ { transform: 'translateX(0)' }, { transform: 'translateX(-8px)' }, { transform: 'translateX(8px)' }, { transform: 'translateX(0)' } ], { duration: 400, easing: 'ease' } );

field.animate() doesn't just start an animation. It returns one — an Animation object, live, with a full set of methods and properties on it: .play(), .pause(), .reverse(), .finish(), .cancel(), .currentTime, .playbackRate, and a .finished property that's an actual Promise, resolving when the animation completes.

JavaScript
1
2
3
4
5
anim.pause(); // freeze exactly where it is anim.currentTime = 150; // jump to 150ms in, no math required anim.reverse(); // now play backward from right here anim.playbackRate = 0.25; // slow it to a crawl await anim.finished; // do something once it's actually done

No class removed. No forced reflow. No parsing a matrix() string to figure out where a transform currently sits. The thing you'd have had to hand-build — a tracked "how far through are we" state — is just .currentTime, maintained by the browser, always correct, in milliseconds.

Runs right in your browser — poke at it and watch the concept react live.

There's one behavior that trips up almost everyone the first time: when the animation finishes, the element snaps back to its pre-animation style. Not a bug — it's the default. CSS has the exact same gotcha (animation-fill-mode defaults to none), it's just less visible because most CSS animations end where they started.

JavaScript
1
2
3
4
const slideIn = card.animate( [{ transform: 'translateY(20px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }], { duration: 300, fill: 'forwards' } // <- without this, it plays, then instantly reverts );

fill: 'forwards' tells it to hold the last keyframe's values after playback ends, instead of letting the element's real styles show back through. Forget it, and your "slide in and stay" animation slides in and then visibly pops back to invisible the instant it finishes — a bug that only shows up on the last frame, which is exactly the frame nobody's watching for in a code review.

Two methods look like they do the same thing and don't. anim.cancel() rips the effect out entirely — currentTime resets to null, playState becomes "idle", and the element's styles revert to whatever they'd be with no animation running at all, fill mode or not. anim.finish() instead jumps straight to the end of the animation and applies whatever fill behavior you set, same as if it had played all the way through in real time.

Use cancel() when you want the animation to have never happened. Use finish() when you want to skip to the result. Mixing them up is how a "skip animation" button quietly turns into a "delete the thing the animation was building toward" button.

None of this needs GSAP, none of it needs a state machine you built yourself. The interactive pieces — pause-on-hover, scrub-to-a-point, reverse-on-condition, speed controls — used to be exactly the reason teams reached for an animation library on top of CSS. element.animate() puts a real handle on the animation instead, and it's been shipping without a vendor prefix in Chrome, Firefox, and Safari for years. If your bundle includes an animation library today, it's worth checking whether it's earning its weight or just wrapping this.

The shake-on-invalid-field from the top of this post, done properly, is one call: keyframes, a duration, and — if you want it to hold its shape instead of just its motion — a fill mode. Everything else is a method call on the object it hands back, not a class name you cross your fingers over.

Go find the CSS animation in your codebase with the ugliest JavaScript wrapped around it — the one with a reflow hack, or a setTimeout guessing at when animationend should have fired. Try rewriting it as one animate() call. What did you get to delete?

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.