The Web Animations API: the engine your CSS compiles to
CSS transitions, CSS keyframe animations, and JavaScript's element.animate() look like three unrelated features, but they are three front-ends to the same underlying timing model — and once you see that model directly, through the Animation objects it actually produces, you get real controls over motion that CSS syntax alone was never designed to expose.
The Web Animations API: the engine your CSS compiles to
You've now seen a CSS transition and, implicitly, a CSS @keyframes animation treated as if they were declarative shortcuts — write the start and end state, or a sequence of them, and the browser advances a value between them, frame by frame, on the compositor when it can. It's natural to assume that's a self-contained feature of CSS, separate from anything JavaScript does. It isn't. Underneath both of them is a single engine — the Web Animations model — and element.animate() is that same engine exposed directly to JavaScript, with no CSS syntax in between. CSS transitions and CSS animations aren't a different system from WAAPI. They're two declarative ways of asking the same engine to do the same job.
This lesson is about that engine: what it's actually built from, how to prove it's the same one underneath all three entry points, and what real capabilities you get once you stop going through CSS and start talking to it directly.
The shared model: effect, timing, timeline
Every animation the browser runs — however it was declared — is represented internally as three parts working together.
An Animation is the object that represents one running (or paused, or finished) animation instance. It's the thing you'd call play() or pause() on.
A KeyframeEffect is what the animation actually does: a target element, a set of keyframes (the property values to hit along the way), and timing information (duration, easing, delay, iteration count). It's the "what changes and over what shape of time" — divorced from any notion of when it started or whether it's currently running.
A timeline provides the animation's sense of "what time is it right now." The default is the document timeline, which advances in lockstep with wall-clock time from page load — the same clock that drives requestAnimationFrame. An Animation is the thing that connects a KeyframeEffect to a timeline: it asks the timeline for the current time, asks the effect what the property values should be at that time, and applies them.
This is the whole point of the lesson in one diagram: a CSS transition and a CSS @keyframes rule are both just declarative syntax for generating a KeyframeEffect and an Animation to drive it. element.animate() skips the CSS syntax and constructs that same effect and that same animation directly. Nothing about the underlying model cares which route produced it.
element.animate(): the engine, called by hand
element.animate(keyframes, options) takes a list of keyframes and a timing configuration and returns a live Animation object — the same kind of object the browser would have created internally for a CSS animation, except now it's a value you're holding.
const anim = box.animate(
[
{ transform: 'translateX(0)', opacity: 1 },
{ transform: 'translateX(300px)', opacity: 0.4 },
],
{
duration: 600,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'forwards',
}
);
anim.playbackRate = 0.5; // run at half speed, mid-flight is fine too
anim.pause();
anim.currentTime = 300; // seek directly to the halfway point
anim.play();
anim.finished.then(() => {
console.log('animation actually completed'); // a real promise, not a guess
});Everything on that Animation object is a real, live control, not a one-shot fire-and-forget:
play()/pause()— start or suspend the animation exactly where it is.reverse()— run the same effect backward from the current position, not from the end.cancel()— stop the animation and remove its effect entirely, snapping the target back to its underlying style.currentTime— read or set the animation's position directly, in milliseconds along its own timeline. Setting it seeks — you can scrub an animation exactly like a video's playhead.playbackRate— a live multiplier on speed.1is normal,2is double speed, a negative value plays it backward, and you can change it while the animation is running.finished— a promise that resolves when the animation completes (and rejects if it's cancelled first), so you can sequence real logic — not asetTimeoutguessing at the duration — off the actual end of the motion.
None of this is CSS. It's a JavaScript object with a genuine API surface, because that's what it actually is under the CSS syntax too.
The proof: getAnimations()
If the claim is that CSS transitions and @keyframes animations are represented by the same Animation objects element.animate() returns, that should be checkable — and it is. Call element.getAnimations() on an element with a CSS transition or animation currently running, and you get back the same kind of Animation object, live, controllable, with the exact same play()/pause()/currentTime/playbackRate/finished surface as one you constructed by hand.
// .card has a CSS transition defined entirely in a stylesheet —
// no JavaScript triggered it.
const [cssAnimation] = card.getAnimations();
cssAnimation.pause(); // pause a CSS transition from JS
console.log(cssAnimation.currentTime); // read exactly how far it's progressed
cssAnimation.playbackRate = 2; // speed up a transition you never startedThere is no special-cased "CSS animation handle" with a smaller API. It's the identical Animation type. That's the concrete, checkable version of "one engine, three front-ends" — you can go grab the object a stylesheet declaration produced and drive it exactly like one you built yourself.
Why this matters: what CSS syntax alone can't give you
CSS transitions and @keyframes are declarative — you describe a static end state (or a static sequence of keyframes) ahead of time, and the browser handles advancing through it. That's genuinely enough for most motion. But several real needs fall outside what static CSS syntax can express, and they all resolve to the same answer: hold the Animation object and drive it imperatively.
- Runtime-computed keyframes. If the target position depends on where the user's cursor is right now, or the size of content that isn't known until render, there's no way to bake that into a stylesheet.
element.animate()takes a keyframes array built from live JavaScript values at call time. - Knowing when it's actually done. A CSS
transitionendevent is one you have to attach and remember to clean up, and it doesn't compose well withPromise-based code.finishedis a promise you canawait, race against other work, or chain — the correct primitive for "run this after the animation genuinely completes." - Scrubbing. Setting
currentTimedirectly lets you tie an animation's position to something continuous — a drag gesture, a scroll position, a video's playhead — none of which CSS transitions can do; a CSS transition only knows "animate from A to B over a fixed duration," not "be at exactly this point right now because the user's finger is there." - Changing speed mid-flight.
playbackRatelets you slow an animation down or speed it up while it's running, in response to something that happened after it started. CSS has no equivalent — a transition's duration is fixed at the moment it starts. - Cancelling cleanly.
cancel()removes the effect and returns the element to its pre-animation computed style in one call, which is a more precise operation than trying to fight a CSS class off mid-transition.
None of these require abandoning CSS for everything — most animations are still better declared in a stylesheet. But when you need one of these five things, you're not missing a CSS feature; you're looking for the object CSS was already creating for you, and getAnimations() or element.animate() is how you get hold of it.
A brief, correct note on composite operations
When multiple animations target the same property on the same element, something has to decide how their effects combine. The default is composite: 'replace' — the later (or higher-priority) animation's value simply overwrites the earlier one for that property. But you can declare composite: 'add', which makes an effect's value combine additively with whatever the property's value already is, rather than replacing it — useful for layering, say, a continuous idle wobble on top of a separate user-driven transform without either animation needing to know about the other's current value. This is a real, specified part of the model, but it's a deep enough topic — additive animations, how they interact with cancellation and interruption — that it belongs to the next lesson rather than a full treatment here.
A brief, correct note on timelines
Everything above assumed the document timeline — wall-clock time since the page loaded — because that's the only timeline most animations ever use, and it's the one both CSS and element.animate() default to implicitly. But the timing model was deliberately built so the timeline is a pluggable input, not a hardcoded assumption: a ScrollTimeline is a timeline whose "current time" is derived from a scroll container's scroll position instead of the clock, which is what lets scroll-driven effects (a progress bar tied to scroll, a parallax effect, an animation that plays exactly once across a scroll range) be expressed as ordinary animations attached to a different clock, rather than as a scroll event handler manually setting styles every frame. The mechanism this lesson describes — effect, timing, an Animation connecting them to a timeline — is unchanged; only where "current time" comes from is different.
Where this goes next
You now have the actual object model underneath every animation on the page, whichever front-end declared it. The next lesson, Interruptible and reversible animations, is built directly on the controls introduced here — currentTime, reverse(), cancel(), and composite operations — to solve the specific, common problem of an animation that needs to change direction or be interrupted cleanly mid-flight without the jarring snap you get from naively swapping CSS classes.
Go deeper
- MDN — Web Animations API — The full reference for Animation, KeyframeEffect, and the timing model this lesson is built on, including the complete set of options element.animate() accepts.
- MDN — Element.getAnimations() — The precise contract for retrieving live Animation objects for CSS-driven animations and transitions — the proof-by-inspection this lesson relies on.
- Chrome Developers — Scroll-driven animations — The concrete detail on ScrollTimeline and scroll-linked effects referenced briefly here as the modern extension of the pluggable-timeline model.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- Name the three parts of the shared timing model and what each one is responsible for.
- What does calling element.getAnimations() prove about the relationship between CSS transitions and the Web Animations API?
- Give two concrete things you can do with an Animation object's currentTime and playbackRate that a plain CSS transition declaration cannot express.
- Why is the finished promise a better fit than a transitionend event listener for running code after an animation completes?
- What is the practical difference between composite: 'replace' and composite: 'add' when two animations target the same property?
- What does it mean for a timeline to be 'pluggable,' and what does a ScrollTimeline substitute in place of wall-clock time?
- A CSS @keyframes animation is running on an element with no JavaScript involved yet. Explain, mechanically, why you can still pause and seek it from JavaScript.