element.animate() vs CSS Keyframes
Part of Web Animations API Integration in Core CSS Animation Fundamentals.
The problem
Both mechanisms produce the same kind of animation on the same timeline, so the choice looks like a style preference — and gets made by whichever one the last developer happened to use. The result is a codebase where half the motion lives in stylesheets and half lives in components, the reduced-motion gate covers only the half in CSS, and nobody can say which effect is responsible for a transform that will not stay put.
The decision is not arbitrary. Three capabilities exist only in the scripted form, and everything outside them is cheaper, safer and more reviewable in the cascade.
Root cause: ownership, not capability
The two forms differ in who owns the animation’s existence.
A CSS animation is owned by the cascade. It exists while a rule matches the element and stops existing when the rule stops matching. Removing a class removes the animation; there is nothing to clean up, nothing to cancel, and no reference held to the DOM node. It also applies before any script has run, which is why a page-load reveal written in CSS is visible during hydration and the same reveal written in JavaScript is not.
A scripted animation is owned by the object returned from element.animate(). It keeps affecting the element regardless of class changes, survives re-renders that leave the node in place, and holds a reference to the target until it is cancelled or garbage collected. That persistence is exactly what a drag gesture needs and exactly what a hover effect does not.
There is one more asymmetry worth knowing. Scripted effects sit above CSS animations in the effect stack, so when both target transform, the scripted one wins irrespective of stylesheet specificity. This surprises people debugging a rule that “should” apply, as covered in animation state management.
Decision matrix
The row that decides most real cases is the second one. If the animation’s start or end value depends on something that only exists at runtime — a pointer position, a measured bounding box, a computed distance — no stylesheet can express it, and every workaround (writing inline custom properties from script, then transitioning them) is a more fragile version of element.animate().
Production pattern: declare in CSS, control in script
The pattern that avoids the false choice is to author the keyframes declaratively and take the handle imperatively.
/* Intent: the values are reviewable in the stylesheet, and the animation is
still available to script as a CSSAnimation object. */
@keyframes card-reveal {
from { opacity: 0; translate: 0 10px; }
to { opacity: 1; translate: 0 0; }
}
.card.is-revealing {
animation: card-reveal 280ms cubic-bezier(0, 0, 0.2, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.card.is-revealing { animation-duration: 1ms; } /* still fires animationend */
}
// Intent: use the cascade for the values, the API for the lifecycle.
async function reveal(card) {
card.classList.add('is-revealing');
// getAnimations() returns the CSSAnimation the class just created.
const [animation] = card.getAnimations();
if (!animation) return; // reduced-motion may have removed it
try {
await animation.finished; // no timers, no animationend plumbing
card.classList.remove('is-revealing'); // resting style already matches
} catch (error) {
if (error.name !== 'AbortError') throw error;
}
}
Rendering Impact:
opacity+translate— composite only.getAnimations()is a read with no layout cost, and awaitingfinishedmoves the completion check off the timer path entirely.
When the values genuinely are runtime-derived, the scripted form takes over completely — but the same discipline applies: compositor-safe properties, a cancel before a retarget, and an explicit preference check, as set out in the parent guide on Web Animations API integration.
Verification checklist
Constraints and trade-offs
- Scripted effects outrank CSS animations on the same property, which makes a stylesheet rule look broken; debug with
getAnimations()rather than the Styles pane. finishednever settles for an infinite effect, so an awaited sequence containing one stalls silently. Keep loops in CSS.- A filling scripted animation is removed when it completes unless persisted or committed, and the element then falls back to its cascaded style.
- The declarative form cannot express a start value that depends on runtime measurement; inline custom properties written from script are a workaround, not a solution.
- Every scripted effect holds a reference to its target, so effects created per interaction and never cancelled keep DOM nodes and their compositor layers alive.
Frequently asked questions
Does element.animate() bypass the cascade entirely?
It bypasses authoring, not the engine. The resulting animation sits in the same effect stack as CSS animations and transitions, and it wins over them by default because scripted effects have a higher composite order. That is why a scripted effect can appear to ignore a stylesheet rule that looks more specific.
Can I write the keyframes in CSS and control playback from JavaScript?
Yes, and it is often the best of both. Declare the @keyframes rule and apply it with a class, then use getAnimations() to grab the resulting CSSAnimation object and call pause, reverse or seek on it. The values stay reviewable in the stylesheet while the lifecycle stays scriptable.
Which one should a design system default to?
CSS. It applies before hydration, is gated by one media query, and cannot leak an object that keeps a DOM node alive. Reserve the scripted form for the three cases it uniquely solves: runtime values, awaited sequencing, and playback control on a live effect.
Related
- Web Animations API Integration — the parent guide covering the API surface and its lifecycle obligations
- CSS Transitions vs Animations — the declarative-side decision this one sits next to
- Coordinating Sequential Animations with Promises — what
await finishedunlocks once the handle exists